laravel-scheduled-sequence maintained by ai-soft
Laravel Scheduled Sequence
Early release
The public API may change before version 1.0.
Database-backed, model-aware scheduled sequences for Laravel 10–13 and PHP 8.1 or newer.
A sequence stores authoritative scheduling state outside the queue. Every due position becomes a durable occurrence with a stable identity before Laravel Queue executes it.
The design evolved from DynamicScheduler, a production implementation used for two years. Scheduled Sequence extracts that proven model into a reusable package and adds durable occurrence identity, concurrency control, recoverable queue publication, catch-up policies, and stale-work protection.
This package is the reference implementation for Laravel Framework Discussion #61689.
Install
composer require ai-soft/laravel-scheduled-sequence:^0.1
Then publish and run the package migrations:
php artisan vendor:publish --tag=scheduled-sequence-config
php artisan vendor:publish --tag=scheduled-sequence-migrations
php artisan migrate
Package discovery registers the provider and commands. Publishing configuration is optional. Publishing and running the migrations is required.
For local path development:
{
"repositories": [{"type": "path", "url": "../package"}],
"require": {"ai-soft/laravel-scheduled-sequence": "@dev"}
}
Create a sequence
php artisan make:scheduled-sequence OutstandingInvoiceReminderSequence
<?php
namespace App\ScheduledSequence;
use AiSoft\ScheduledSequence\Occurrence;
use AiSoft\ScheduledSequence\ScheduledSequence;
use App\Jobs\SendInvoiceReminder;
final class OutstandingInvoiceReminderSequence extends ScheduledSequence
{
protected array $offsets = [
'now',
'1 day 10am',
'3 days 10am',
'7 days 10am',
];
protected ?string $repeatEveryAfterLastOffset = '5 days';
protected function shouldContinue(Occurrence $occurrence): bool
{
return $occurrence->sequence->sequenceable?->isOutstanding() === true;
}
protected function handle(Occurrence $occurrence): void
{
SendInvoiceReminder::dispatch(
invoiceId: $occurrence->sequence->sequenceable_id,
occurrenceKey: $occurrence->key,
);
}
}
Start or restart it for a model:
OutstandingInvoiceReminderSequence::start($invoice, $invoice->customer_id);
Restarting the same handler/model pair increments definition_version. Queued occurrences from the older definition become stale and do not execute.
Offsets use formats accepted by PHP DateTime::modify, must be unique after normalization, and must move forward in their declared order.
Execution contract
Scheduled Sequence owns when work becomes due and when it is durably handed off. Laravel Queue owns execution attempts and retry timing after handoff.
The runner:
- locks a due sequence row in a database transaction;
- creates a uniquely identified occurrence;
- advances the sequence in the same transaction;
- publishes the pending occurrence to Laravel Queue;
- recovers pending or abandoned work on later runs.
Occurrence identity is stable across retries:
(sequence_id, definition_version, occurrence_number)
$occurrence->key is an opaque idempotency key suitable for passing to application jobs and external integrations.
Before handle runs, the package reloads the sequence, validates its definition version and cancellation status, and calls shouldContinue. A cancelled, restarted, or rejected occurrence is skipped.
If the sequenceable model has been deleted, the occurrence becomes stale and the sequence is cancelled instead of invoking application handling.
Exactly-once external effects are not promised. Use the occurrence key with integrations that support idempotency.
The recoverable publication contract covers both sides of the queue boundary:
- if the process stops after committing the occurrence but before queue acceptance, a later publisher sends that pending occurrence;
- if the queue accepts the message but the publisher stops before recording
published, recovery may send the same occurrence again.
Every delivery uses the same occurrence identity. Accepted occurrences must eventually make progress, while duplicate delivery must not create duplicate effects inside the package-controlled execution boundary. External systems still require their own idempotency support.
Reliability tests
The regular package suite uses SQLite and covers the Laravel 10–13 compatibility matrix. A separate real-process suite uses database queues and deterministic barriers against MySQL and PostgreSQL:
RUN_RELIABILITY_TESTS=1 \
RELIABILITY_DB_CONNECTION=mysql \
RELIABILITY_DB_DATABASE=scheduled_sequence \
RELIABILITY_DB_USERNAME=root \
RELIABILITY_DB_PASSWORD=password \
composer test:reliability
Set RELIABILITY_DB_HOST and RELIABILITY_DB_PORT when the database does not use the defaults. GitHub Actions runs this suite for both supported production database engines.
Runner registration
The provider registers this command with Laravel Scheduler every minute by default:
php artisan scheduled-sequence:run
The server still needs Laravel's normal scheduler trigger:
* * * * * cd /path-to-project && php artisan schedule:run >> /dev/null 2>&1
Set register_scheduler to false in the published configuration when the application wants to register the command itself.
Run queue workers for the configured queue connection. With the sync queue connection, occurrences execute inside the runner process and Laravel does not provide asynchronous retry attempts.
Catch-up and recurrence
The default catch-up policy is coalesce_latest: when several positions became due during downtime, one occurrence is created for the latest due position.
Per sequence, choose:
use AiSoft\ScheduledSequence\Enums\CatchUpPolicy;
protected ?string $catchUpPolicy = CatchUpPolicy::COALESCE_LATEST;
// CatchUpPolicy::REPLAY_ALL
// CatchUpPolicy::SKIP
REPLAY_ALL is bounded by replay_limit. Recurrence remains anchored to intended scheduled time rather than worker completion time.
Daily local-clock recurrence is tested across forward and backward daylight-saving transitions. The package timezone defaults to the Laravel application timezone; keep them aligned when offsets represent local wall-clock time.
Repeat after the finite prefix:
protected ?string $repeatEveryAfterLastOffset = '5 days';
Repeat the complete offset sequence:
protected bool $repeatSequence = true;
Guard application jobs
When handle dispatches another job that may wait independently, pass the occurrence key and validate it immediately before its side effect:
use AiSoft\ScheduledSequence\Services\OccurrenceGuard;
public function handle(OccurrenceGuard $guard): void
{
if (! $guard->allows($this->occurrenceKey)) {
return;
}
// Perform the external side effect with the same idempotency key.
}
The package reliability suite also verifies that retries and independently queued application work receive the same occurrence key. The receiving integration must use that key when applying an idempotent external effect.
Memory and retention
Store small application-owned markers in the sequence record:
$record = $sequence->getSequenceRecord();
$record->remember('notices.initial.sent_at', now()->toIso8601String());
$record->recall('notices.initial.sent_at');
Keep terminal state permanently when it is part of the business audit trail:
protected bool $rememberPermanently = true;
Non-permanent terminal sequences are retained temporarily so queued jobs can validate their version and status, then pruned after terminal_retention_seconds. Failed occurrences block automatic pruning for diagnosis.
Documentation
Versioning
This package follows Semantic Versioning. Until 1.0.0, the public API is
unstable and may change between minor releases. Laravel compatibility is
declared independently through Composer constraints.
Test
composer install
composer check
composer check validates the optimized PSR-4 autoloader, checks formatting with
the PSR-12 preset (the current replacement for PSR-2), and runs PHPUnit.
License
Laravel Scheduled Sequence is open-source software licensed under the MIT license.