Let's Connect

Developer workspace with a laptop showing code, representing a Laravel command processing an inbox over IMAP

If you run a shared Google Workspace inbox like accounts@ or invoices@ where vendors send PDFs all day, someone is manually downloading those attachments and dropping them into a folder. That is a job for a machine. Wiring up laravel imap email attachments lets a scheduled Artisan command connect to the mailbox over IMAP, pull every new attachment, save it to your storage disk, and flag the message as processed so it never gets filed twice. I have run this exact pattern in production against Gmail mailboxes receiving a few hundred invoices a week, and the whole thing is about forty lines of code once the auth is sorted.

Which package and how do I install it?

Use webklex/laravel-imap. It is the maintained Laravel wrapper around the php-imap library, it ships a facade and config file, and it handles attachment decoding so you do not touch raw MIME. Install it and publish the config:

bash
composer require webklex/laravel-imap

php artisan vendor:publish --provider="Webklex\IMAP\Providers\LaravelServiceProvider"

That drops a config/imap.php file. You will not edit it much — every value you care about reads from environment variables, which is where the next gotcha lives.

Why does my Gmail password not work over IMAP?

Because Google killed less-secure-app access years ago. Your normal account password will be rejected outright. You have two real options: an App Password, or full OAuth2. For a server-side daemon hitting one fixed mailbox, an App Password is far simpler — but it requires 2-Step Verification to be enabled on that account first, otherwise the App Passwords screen does not even appear. Generate one at the Google Account security page, and treat it like a secret because it bypasses 2FA entirely.

You also have to flip on IMAP access in the Gmail settings for that account (Settings, then Forwarding and POP/IMAP, enable IMAP). Miss that and you will get authentication errors that look like a credentials problem but are not. Here is the .env block for a Google Workspace mailbox:

.env
IMAP_HOST=imap.gmail.com
IMAP_PORT=993
IMAP_ENCRYPTION=ssl
IMAP_VALIDATE_CERT=true
IMAP_USERNAME=invoices@your-domain.com
IMAP_PASSWORD=your-16-char-app-password
IMAP_DEFAULT_ACCOUNT=default

Keep IMAP_VALIDATE_CERT=true. People disable cert validation the moment they hit a TLS error and it is almost always the wrong fix — you are turning off the one thing protecting the connection to paper over a CA bundle issue on the box.

Screen full of code representing the Laravel Artisan command that connects over IMAP and loops through message attachments
The fetch command is small: connect, query unseen messages, loop attachments, flag processed.

How do I write the command that pulls attachments?

Generate a command and put the IMAP logic in its handle method:

bash
php artisan make:command FetchInboxAttachments

The flow is: get the account from the facade, connect, open INBOX, query only unseen messages from the last few days, then loop. For each attachment, write it to the storage disk under a path keyed by the message UID so collisions are impossible, then flag the message Seen so the next run skips it.

app/Console/Commands/FetchInboxAttachments.php
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Webklex\IMAP\Facades\Client;

class FetchInboxAttachments extends Command
{
    protected $signature = 'inbox:fetch';
    protected $description = 'Pull new email attachments from the shared inbox into storage';

    public function handle(): int
    {
        $client = Client::account('default');
        $client->connect();

        $folder = $client->getFolder('INBOX');

        $messages = $folder->query()
            ->unseen()
            ->since(now()->subDays(7))
            ->leaveUnread()   // we control the Seen flag ourselves, below
            ->get();

        foreach ($messages as $message) {
            $uid = $message->getUid();

            foreach ($message->getAttachments() as $attachment) {
                $path = "invoices/{$uid}/" . $attachment->getName();
                Storage::disk('local')->put($path, $attachment->getContent());
                $this->info("Saved {$path}");
            }

            // Only flag once the attachments are safely on disk.
            $message->setFlag('Seen');
            // Or, to keep INBOX clean: $message->move('INBOX/Processed');
        }

        return self::SUCCESS;
    }
}

Note the ordering: I set the Seen flag (or move the message) only after the writes succeed. If saving throws, the message stays unseen and the next run retries it. Flag first and a storage failure means you have silently lost that invoice.

How do I run it on a schedule without it stepping on itself?

Register it in the scheduler and guard it with withoutOverlapping so a slow run (large attachments, a flaky connection) never has a second instance start on top of it:

routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('inbox:fetch')
    ->everyFiveMinutes()
    ->withoutOverlapping();

That assumes your system cron is actually calling php artisan schedule:run every minute. If you want this running as a resilient background process rather than relying on cron — especially if you later move the fetch into a queued job — set up a supervised worker as I covered in running Laravel queue workers under Supervisor in production.

Mark the message processed the instant the attachment hits disk — every re-run that double-files an invoice is a bug your accountant finds before you do.Md Raihan Hasan

What bites people in production?

Three things, and they all come from the same root cause — treating the mailbox as if it were small and the runs as if they were unique:

  • Unbounded queries. Never fetch the whole mailbox. Always constrain with unseen() and since(), and for big inboxes use the query builder's chunked() callback so you are not loading thousands of message bodies into memory at once.
  • Double-filing on re-runs. setFlag('Seen') or move() is your idempotency guard. Without it, the next run re-downloads everything that is still marked unread.
  • Trusting Seen alone for dedupe. The Seen flag is mailbox state, not yours. If someone opens the inbox in a browser, messages get marked read out from under you. Persist each processed message id (getMessageId() or the UID) to a small table and skip anything you have already recorded — that survives flag changes and account migrations.
  • Storing app secrets next to the attachments. The App Password bypasses 2FA, so keep it in .env and out of version control, and rotate it if the box is ever compromised.

Once attachments are landing reliably, the same mailbox usually becomes a sending point too — confirmations, parsed-receipt notifications, bounce handling. If you are about to send mail from the same domain, get the SPF, DKIM, and DMARC records right before you send a single message, or your confirmations land in spam. For the IMAP side specifically, the Webklex package's official documentation is the authoritative reference for the query builder and attachment methods if you need to go deeper than this.

The whole pattern is deceptively small, and that is the point: a connection, a constrained query, a loop, and one flag. The engineering that matters is not the IMAP call — it is ordering the writes before the flag, deduping on a stored id instead of trusting Seen, and bounding every query so a five-thousand-message inbox does not take your worker down. Get those three right and this command will quietly file documents for years without anyone thinking about it.