Laravel's task scheduler is a small piece of infrastructure that ends up carrying a lot of weight in an ERP integration: nightly syncs pulling orders from an e-commerce channel into an ERP system, invoice generation runs, stock reconciliation jobs, retry sweeps for records that failed to post earlier in the day. It's one cron entry (`* * * * * php artisan schedule:run`) and a fluent API in `routes/console.php`, but the failure modes that matter are specific to running recurring jobs against an ERP's API rather than against your own database.
Overlapping runs are the first thing that breaks
An ERP sync scheduled every five minutes that occasionally takes seven minutes will, sooner or later, overlap with itself — two instances of the same sync job running concurrently, both trying to create the same Sales Order from the same source record. `withoutOverlapping()` is the fix, but it depends on the cache driver being genuinely shared and lock-capable across whatever's running the scheduler; on a multi-server or multi-container deployment, an isolated file cache per instance means the lock never actually prevents overlap. Redis or the database cache driver, shared across every process that might run the schedule, is required for the lock to do anything.
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('erp:sync-orders')
->everyFiveMinutes()
->withoutOverlapping(600) // release lock after 10 min even if crashed
->onOneServer() // only one node runs it in a multi-server setup
->runInBackground()
->appendOutputTo(storage_path('logs/erp-sync.log'))
->emailOutputOnFailure(config('ops.alert_email'));
Schedule::command('erp:reconcile-stock')
->dailyAt('02:00')
->withoutOverlapping()
->onFailure(fn () => Log::critical('Stock reconciliation failed'));
"One server" does not mean one server forever
`onOneServer()` is easy to add and easy to forget you added — it depends on the cache lock working the same way `withoutOverlapping` does, and it silently does nothing useful if you're running a single server today and add a second one later without re-verifying the cache driver is shared. Before scaling an ERP integration horizontally, explicitly test that a scheduled sync run really does execute on exactly one node, not by reading the code but by actually deploying two nodes and watching the logs during a run.
A sync job that times out waiting on the ERP's REST API doesn't necessarily mean the request failed server-side — the ERP may have created the record and simply taken too long to respond. Blindly retrying on timeout risks duplicate Sales Orders or duplicate invoices. Idempotency keys (a reference number generated client-side and checked before insert) or a "does this order already exist by external ID" lookup before creating anything is mandatory for any retried ERP write, not optional hardening.
Scheduled jobs should dispatch to queues, not run inline
A scheduled command that does the ERP sync work directly, inline, ties up the scheduler process for the duration of the sync and makes failure handling harder — a crash mid-sync leaves you guessing which records were processed. The more robust shape is a scheduled command that enqueues one job per batch of records (or one job per record for smaller volumes), so Laravel's queue retry, backoff, and failed-job tooling apply automatically, and a partial failure only affects the batch that failed rather than the entire run.
Clock drift and timezone bugs are real
`dailyAt('02:00')` runs at 2am in whatever timezone the scheduler's config is set to, which defaults to the app's `config('app.timezone')` unless overridden per schedule with `->timezone()`. A container running in UTC scheduling an ERP invoice run meant for 2am East Africa Time will actually run at 2am UTC — 5am local — and if that's during a maintenance window on the ERP side, the job fails consistently and confusingly. Set the timezone explicitly per scheduled entry rather than assuming the container's default matches the business's expectations.
If the system cron entry that calls schedule:run ever stops firing — a deploy that resets crontab, a container restart that drops it — every scheduled ERP job simply stops running with no error anywhere, because there's no process left to report the failure. A dead man's switch (a heartbeat ping to an external monitor on every schedule:run tick) catches this class of failure; application-level error handling inside the jobs themselves cannot, because the jobs never run at all.
Wrapping up
Laravel's scheduler is simple by design, which is exactly why the failure modes around ERP integration jobs are worth taking seriously: use a shared cache driver so withoutOverlapping and onOneServer actually lock, make ERP writes idempotent so a timeout-triggered retry can't duplicate a record, dispatch real work to queued jobs instead of running it inline, and monitor that the scheduler itself is still alive — not just that the jobs it runs are succeeding.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.