shipsaas/laravel-inbox-process
Laravel package for processing incoming email in your application. Connect an inbox, parse messages, and route them to your own handlers, making it easier to build support desks, reply threads, and notification workflows with less boilerplate and clearer separation of concerns.
composer require <vendor>/inbox
php artisan vendor:publish --tag="inbox-config"
php artisan vendor:publish --tag="inbox-migrations"
php artisan migrate
config/inbox.php first — the only knob you must know about is route_path (default inbox). The package registers its HTTP endpoint at /{route_path}, so with defaults a POST /inbox is what external systems should call.php artisan route:list --path=inbox
https://yourapp.test/inbox locally via a tunnel, fire one event, and verify a row landed in the inbox table:
DB::table('inbox_messages')->latest()->first();
Request class that gates what is allowed in (authorize + rules), and a consumer (job/command/scheduled task) that reads, resolves, and flushes messages.Gate ingress with a custom Request class. Since v1.1.2 the package's HTTP layer runs both authorize() and rules() on the request class bound to the endpoint. Verify the sender in authorize() and shape-check the body in rules():
class StripeWebhookRequest extends InboxRequest
{
public function authorize(): bool
{
return hash_equals(
config('services.stripe.webhook_secret'),
$this->header('X-Signature', '')
);
}
public function rules(): array
{
return [
'id' => ['required', 'string'],
'type' => ['required', 'string'],
'data' => ['required', 'array'],
];
}
}
Keep signature checks in authorize() rather than rules(): a failed authorization is a 403 and never touches the database, which is exactly the behaviour you want for spoofed traffic.
One request class per producer. Do not overload a single class with switch logic across Stripe/GitHub/internal sources. Separate classes keep rules honest and let you tighten one integration without risking another. Register/bind them the way the package docs specify (typically a config map or a container binding) and add a test per class.
Resolve-then-flush as the read path. The inbox is a buffer, not an audit log. The idiomatic loop is: pick up unresolved rows, dispatch a job per message, mark resolved on success, then prune. Schedule the flush rather than growing the table forever:
Schedule::command('inbox:flush --resolved --older-than=7')->daily();
SQLite is a legitimate production choice here when the inbox is a short-lived staging area — messages arrive, get resolved within seconds or minutes, and get flushed. Pair it with WAL mode and a busy timeout so a burst of webhooks does not throw database is locked:
// config/database.php
'sqlite' => [
'driver' => 'sqlite',
'database' => database_path('database.sqlite'),
'foreign_key_constraints' => true,
'busy_timeout' => 5000,
'journal_mode' => 'WAL',
],
If you also queue on SQLite, keep the inbox table in the same file but be aware both write paths now serialize — see Gotchas.
Environment-specific route paths. route_path makes it easy to keep the endpoint unguessable in public environments without code changes:
// config/inbox.php
'route_path' => env('INBOX_ROUTE_PATH', 'inbox'),
INBOX_ROUTE_PATH=hooks/8f3c1a9b
This is obscurity, not security — authorize() still has to do the real work.
Integration tips.
bootstrap/app.php (or VerifyCsrfToken::$except) if you are posting from a browser context.->middleware('throttle:120,1') on the registered route group.$this->postJson('/inbox', $payload, $headers) and assert both the 403 path (bad signature) and the 422 path (bad body), not just the happy path.Re-cache routes after changing route_path. php artisan route:cache bakes in the old path. If you flip INBOX_ROUTE_PATH in production and forget php artisan route:clear && php artisan route:cache, requests will 404 against a route that exists in config. Always deploy the config change and the route cache rebuild together.
Authorize failure is silent to the sender by design. A 403 from authorize() and a 422 from rules() look nearly identical from the outside. Log both explicitly (including the source IP and header names, never full secrets) or you will spend an afternoon asking a provider why "your webhook is failing" with no server-side trail.
Validate the raw body, never the validated array, when checking a signature. Most signing schemes hash the exact bytes sent. If you compute the HMAC over $this->validated() or a re-encoded JSON payload, whitespace and key-ordering differences will produce intermittent failures that only show up with certain senders. Read $this->getContent() in authorize().
authorize() must return a real boolean. Returning a truthy string or an array will pass the check but will confuse strict comparison paths and static analysis. Also remember authorize() runs before validation — do not assume $this->input('foo') exists there.
SQLite concurrency. With busy_timeout unset, a handful of simultaneous webhooks can throw SQLITE_BUSY. Set the timeout (5s is usually plenty) and enable WAL. Avoid long-running transactions that hold a write lock while a queue worker is also writing. If throughput matters more than the SQLite convenience, keep MySQL/Postgres for the inbox table regardless of what the rest of the app uses.
database_path('database.sqlite') may not exist on a fresh deploy. SQLite will happily create an empty file and then fail with a confusing "no such table" later. Ensure the file exists, is writable by the web user, and that migrations ran against the same absolute path — relative paths resolve differently for php artisan versus the PHP-FPM process.
Flushing is destructive; make the retention window explicit. Prefer --older-than over "flush everything" in any scheduled command. A --resolved flag protects in-flight messages, but only if you actually mark messages resolved — a job that throws mid-processing leaves the row unresolved, which is correct behaviour but means a poison message will sit in the inbox forever. Add a max-attempts or quarantine step.
Extension points to reach for before forking. The published config is the intended surface: route path, middleware, model/request-class bindings. If you need to alter storage, check whether the model is configurable before subclassing the HTTP layer — subclassing the controller/route registration tends to break on minor upgrades because the endpoint registration is internal.
Upgrading to 1.1.2 is additive. route_path, SQLite support, and the authorize/rules hooks are new capability, not a rename or removal. Existing installs keep working with the default inbox path, and request classes without authorize/rules do not suddenly start rejecting traffic. Re-run vendor:publish (without --force) after upgrading to pick up the new config key, then merge route_path into your existing config/inbox.php manually — --force will overwrite local customizations.
How can I help you explore Laravel packages today?