- Introduction
- Quickstart
- Dispatchable Jobs
- Command Bus
- Job Middleware
- Rate Limiting
- Callback Processor
- Sync Mode
- Events
CakePHP Queue (crustum/cakephp-queue, namespace Crustum\Queue) wraps cakephp/queue with helpers for self-dispatching jobs, static queue configuration, tags, optional sync (in-process) dispatch, and dispatch lifecycle events.
Jobs can call ::dispatch() or ::dispatchLater() through DispatchableTrait, optionally define their own queue settings with getQueueConfig(), and attach tags via createTags(). Before enqueue, JobDataMutators::prepare() can inject application fields into job data (for example Speculum’s speculum_uuid). Every dispatch then fires Crustum/Queue.Job.pending then Crustum/Queue.Job.pushed. When sync mode handles a job, push is skipped and the job runs in-process via Cake Queue Processor (same DI and Processor.message.* events as a worker). Applications may register an extra emitter with JobDispatchEmitters::set() for their own side effects without replacing those events.
Install via Composer:
composer require crustum/cakephp-queueNote
This plugin should be registered in your config/plugins.php file.
bin/cake plugin load Crustum/QueueAlternatively, load the plugin in config/plugins.php:
'Crustum/Queue' => [
'bootstrap' => true,
'routes' => false,
],Or in Application.php:
// In src/Application.php
public function bootstrap(): void
{
parent::bootstrap();
$this->addPlugin('Crustum/Queue');
}You must also configure cakephp/queue (Queue config + workers) as usual.
Publish optional plugin config (requires crustum/plugin-manifest in the application):
bin/cake manifest install --plugin Crustum/QueueThat copies config/crustum_queue.php into the application. Sync defaults:
// config/crustum_queue.php
'CrustumQueue' => [
'sync' => filter_var(env('CRUSTUM_QUEUE_SYNC', false), FILTER_VALIDATE_BOOLEAN),
'syncOnly' => [
// \App\Job\CriticalPathJob::class,
],
],The plugin bootstrap loads CONFIG/crustum_queue.php when present, otherwise the plugin’s own config/crustum_queue.php.
use Cake\Queue\Job\JobInterface;
use Cake\Queue\Job\Message;
use Crustum\Queue\Job\DispatchableInterface;
use Crustum\Queue\Job\DispatchableTrait;
use Interop\Queue\Processor;
class ExampleJob implements JobInterface, DispatchableInterface
{
use DispatchableTrait;
public function execute(Message $message): ?string
{
return Processor::ACK;
}
}
ExampleJob::dispatch(['id' => 1]);
ExampleJob::dispatchLater(['id' => 1], 30);Implement Crustum\Queue\Job\DispatchableInterface and use DispatchableTrait on a Cake\Queue\Job\JobInterface class.
dispatch():
- Resolves queue options (defaults or
getQueueConfig()) - Merges tags (payload +
TaggableInterface+ job class) - Ensures
_uniqueIdon the payload - Runs data mutators
- Emits pending →
QueueManager::push→ emits pushed or (sync): pending → pushed (sync => true) →SyncJobRunner(skip push)
dispatchLater() always stays async (delay is never executed in-process).
Implement ConfigurableInterface and define static configuration on the job:
use Crustum\Queue\Job\ConfigurableInterface;
use Crustum\Queue\Job\DispatchableInterface;
use Crustum\Queue\Job\DispatchableTrait;
class EmbedJob implements JobInterface, DispatchableInterface, ConfigurableInterface
{
use DispatchableTrait;
public static ?int $maxAttempts = 3;
public static function getQueueConfig(): array
{
return [
'config' => 'default',
'queue' => 'default',
'maxAttempts' => self::$maxAttempts ?? 3,
'retryDelay' => 60,
];
}
// execute(...)
}Named CakePHP Queue configs must exist under Configure Queue.{name} and be registered with QueueManager (via the Cake/Queue plugin bootstrap).
use Crustum\Queue\Job\TaggableInterface;
class TaggedJob implements JobInterface, DispatchableInterface, TaggableInterface
{
use DispatchableTrait;
public static function createTags(array $data): array
{
$package = $data['package'] ?? null;
return is_string($package) ? ['package:' . $package] : [];
}
// execute(...)
}Tags from the payload, createTags(), and the job class name are merged uniquely into $data['tags'].
When global sync is on, implement this to keep a job on the broker:
use Crustum\Queue\Job\SyncSuppressibleInterface;
class HeavyReportJob implements JobInterface, DispatchableInterface, SyncSuppressibleInterface
{
use DispatchableTrait;
public static function suppressSync(array $data = []): bool
{
return true;
}
// execute(...)
}The plugin provides a CommandBus facade for dispatching typed command objects to handler jobs. Commands are lightweight DTOs that carry the intent and payload; the bus maps a command class to its handler job class and delegates to the handler's static dispatch — sync/async is decided by CrustumQueue as usual.
The command DTO is a leaf: it implements CommandMessage and knows nothing about jobs, the queue, or the bus. Dependencies flow downward only — app code → command → bus → job → queue, and nothing points back up. So:
- App code never imports job classes. It depends only on the command DTO and the bus, so swapping a handler job for another (or a different queue backend) does not touch callers.
- Commands are plain data. They are safe to construct, serialize, and pass around anywhere; they carry the intent, not the execution.
- The bus owns the mapping. The command does not need to know which job runs it, and the job's
#[Handles]attribute keeps the command class itself dependency-free.
Implement Crustum\Queue\CommandMessage on a DTO to make it dispatchable:
use Crustum\Queue\CommandMessage;
class SendWelcomeEmailCommand implements CommandMessage
{
public function __construct(
public readonly int $userId,
public readonly string $locale = 'en',
) {
}
public function payload(): array
{
return ['user_id' => $this->userId, 'locale' => $this->locale];
}
public static function fromPayload(array $data): static
{
return new self(
userId: (int)($data['user_id'] ?? 0),
locale: (string)($data['locale'] ?? 'en'),
);
}
}payload() produces the JSON-safe body for the queue; fromPayload() rebuilds the command in the handler's execute().
Register a command→job pair either manually or via attributes:
use Crustum\Queue\CommandBus;
CommandBus::map(SendWelcomeEmailCommand::class, SendWelcomeEmailJob::class);CommandBus::dispatch(new SendWelcomeEmailCommand(userId: 42));
CommandBus::dispatchLater(new SendWelcomeEmailCommand(userId: 43, locale: 'fr'), 30);dispatch() delegates to the handler job's ::dispatch(), so sync mode runs the job in-process and async mode pushes to the broker — identical to direct SendWelcomeEmailJob::dispatch().
Both accept queue $overrides (e.g. ['sync' => true]) that are forwarded to the handler's static dispatch. Dispatching a command with no registered handler throws a RuntimeException (never a bare undefined-key notice).
Instead of a manual map, mark the handler job with #[Handles] and discover all handlers from your Job folders:
use Crustum\Queue\CommandMessage;
use Crustum\Queue\Handles;
#[Handles(SendWelcomeEmailCommand::class)]
class SendWelcomeEmailJob implements JobInterface, DispatchableInterface
{
use DispatchableTrait;
public function execute(Message $message): ?string
{
$command = SendWelcomeEmailCommand::fromPayload($message->getArgument());
// $command->userId, $command->locale …
return Processor::ACK;
}
}Then build the map in bootstrap:
CommandBus::registerFromAttributes();Discovery scans the app's Job folder (and each loaded plugin's Job folder) via the attribute resolver (crustum/cakephp-attribute-resolver); vendor is excluded. registerFromAttributes() is idempotent — calling it twice rebuilds the same map. Manual map() calls remain available as a fallback or for cases outside the scanned scope. The scan is customizable via $paths, $basePath, and $excludePaths (defaults: ['Job/*.php'], ROOT/src, ['vendor', 'tests', 'build', 'tmp']).
Per-job execution middleware for queue jobs. A job declares a stack, and each middleware either calls $next($job, $message) to run the rest of the chain or returns early to short-circuit it. Skipping returns ACK without running the job; deferring re-pushes a delayed copy and ACKs the current one; failing returns REJECT into the regular failed-job path.
The pipeline is enforced by a processor class, not by the jobs themselves. Crustum\Queue\Processor\MiddlewareProcessor extends the Cake Queue Processor and overrides a single choke point — processMessage(). For every message it resolves the job once (container or plain new), reads the stack from MiddlewareAwareInterface::middleware(), and runs the shared MiddlewareRunner core around the job invocation. Consequences of this shape:
- Enforcement is global: any middleware-aware job gets the pipeline with zero per-job boilerplate. Jobs without middleware behave exactly as before (empty stack falls through to the parent call).
- Worker and sync share it: both paths resolve the same
processorqueue config key, soSyncJobRunnerneeds no changes. - The resolved instance is the executed instance: middleware configures the same object that runs, so injected dependencies never diverge between the stack and
execute().
Custom processors (batch, callback-style, domain-specific) reuse the identical core at their own inner-execution point via RunsMiddlewareTrait — one pipeline implementation, every entry point covered. See Pipeline for wiring, Built-in Middleware for the ports, MiddlewareJob Base Class for the ergonomic subclass.
Implement Crustum\Queue\Contract\MiddlewareAwareInterface on the job and return middleware from middleware(). The Message argument allows argument-aware, container-resolved stacks:
use Cake\Queue\Job\Message;
use Crustum\Queue\Contract\MiddlewareAwareInterface;
use Crustum\Queue\Contract\MiddlewareInterface;
use Crustum\Queue\Middleware\Skip;
class SendEmailJob implements JobInterface, DispatchableInterface, MiddlewareAwareInterface
{
use DispatchableTrait;
/**
* @return list<MiddlewareInterface>
*/
public function middleware(Message $message): array
{
return [
Skip::when($message->getArgument('dry_run') === true),
];
}
// execute(...)
}Enable the pipeline with one config line (worker and sync paths both resolve the same key, so no SyncJobRunner changes are needed):
'Queue' => ['default' => [
// ...
'processor' => \Crustum\Queue\Processor\MiddlewareProcessor::class,
]],Custom processors reuse the same shared core at their inner-execution point via RunsMiddlewareTrait (resolve once, then runWithMiddleware()), so middleware also applies there. Custom middleware implements MiddlewareInterface::handle(object $job, Message $message, callable $next): mixed.
Middleware\Skip::when()/::unless()— guard; skips without calling$nextand returnsACK. Sync-safe.Middleware\Release::when()/::unless()— conditional deferral; re-pushes a delayed copy viaQueueManager(CakeREQUEUEis delay-less) andACKs the current one. Sync-unsafe: releasing in sync mode throws a descriptive exception, since there is no "later" in-process.Middleware\FailOnException— fail-fast policy; takes a truth-test callback or an exception class list. Matched exceptions jump the messageattemptsto the jobstatic::$maxAttempts, so the regular failed-job path records them without burning the retry budget. Unmatched exceptions propagate untouched. Sync-safe.Middleware\ThrottlesExceptions— cross-message failure counting with decay and backoff overRateLimiter. Over budget defers; success clears the counter; exceptions support report gates, delete-fast (REJECT, no failed row), fail-fast (same sink asFailOnException), otherwise hit the counter and defer with backoff. Fluent configuration:setWhen(),addDeleteWhen(),addFailWhen(),setReport(),setKey(),useJobKey(),setPrefix(),setBackoff(),setLimiter(). Deferral paths are sync-unsafe.Middleware\WithoutOverlapping— execution-time mutex on Cake Lock. The first holder runs (released intry/finally); contention re-pushes with delay, or drops the job withdisableRelease(). Fluent configuration:setReleaseAfter(),disableRelease(),setExpireAfter(),setPrefix(),setShared(), plus the lock config name in the constructor. Use a non-file lock backend (Redis, Memcached) for distributed workers; the File engine is single-server and has no TTL expiry.Middleware\RateLimited— named-limiter execution gate (see Rate Limiting). Over budget defers (or drops withdisableRelease()); under budget hits every limit and runs. Unregistered limiter names andUnlimitedpass through. Deferral paths are sync-unsafe.
Extend Crustum\Queue\Job\MiddlewareJob for new jobs instead of wiring the runner by hand. final execute() makes the pipeline wrapper unskippable within the hierarchy; subclasses implement doExecute() plus optionally middleware():
use Crustum\Queue\Job\MiddlewareJob;
class SendEmailJob extends MiddlewareJob
{
protected function doExecute(Message $message): ?string
{
// real work, no pipeline boilerplate
return Processor::ACK;
}
public function middleware(Message $message): array
{
return [new Skip($message->getArgument('dry_run') === true)];
}
}Inside execute(), $this is the container-resolved executing instance, so injected dependencies are shared between middleware() and doExecute() with no extra discipline. Opt-in by inheritance: jobs implementing JobInterface directly bypass the pipeline.
Attempt counters with decay windows over Cake Cache (port of Illuminate\Cache\RateLimiter plus Limiters). Counters need an atomic increment — use Redis, Memcached, or APCu; the File engine is read-modify-write and local-dev only (same rule as the queue uniqueCache). Timer TTLs come from the cache config duration, so it must cover the longest decay used.
use Crustum\Queue\Limiter\RateLimiter;
$limiter = new RateLimiter('limiter_cache_config');
$limiter->hit('emails', 60); // record one hit, returns the new count
$limiter->isLimited('emails', 100); // over budget within the window?
$limiter->getAvailableIn('emails'); // seconds until accessible again
$limiter->getRemaining('emails', 100); // attempts left
$limiter->getAttempts('emails'); // current count
$limiter->clear('emails'); // reset count and timer
$limiter->resetAttempts('emails'); // reset count only
$limiter->attempt('emails', 100, $callback, 60); // run unless limited (false when limited)Named limiters are registered with resolvers and read back by name. Resolvers receive the job and the message and return a Limit, an Unlimited, or a list of Limit definitions (duplicate keys get collision-free fallback keys):
$limiter->setLimiter('sending', static fn(object $job, Message $message): Limit => Limit::perMinute(100)->setKey('emails'));
$limiter->hasLimiter('sending'); // check registration
$limiter->getLimiter('sending'); // resolver closure (null when unregistered)Crustum\Queue\Limiter\Limit definitions are built with factories and tuned fluently:
use Crustum\Queue\Limiter\Limit;
Limit::perSecond(60);
Limit::perMinute(100)->setKey('emails');
Limit::perMinutes(5, 100);
Limit::perHour(500);
Limit::perDay(1000);
Limit::none(); // Unlimited — always runssetKey() scopes the counter, setAfterCallback() / setResponseCallback() attach HTTP-layer hooks, getFallbackKey() derives a collision-free key for duplicated definitions. GlobalLimit is a keyless limit; Unlimited is a pass-through with PHP_INT_MAX attempts.
Slot-based concurrency caps over Cake Lock (up to N holders at once, slots carry a TTL so crashed holders stop blocking):
use Crustum\Queue\Limiter\ConcurrencyLimiterBuilder;
(new ConcurrencyLimiterBuilder('lock_config', 'sending'))
->setMaxLocks(5)
->setReleaseAfter(60)
->setTimeout(3)
->setSleep(250)
->then(
static fn() => sendBatch(),
static fn($e) => logBusy(),
);then() runs the callback inside a slot and releases it afterwards (also on exception); on timeout it calls the failure callback or throws LimiterTimeoutException.
Crustum\Queue\Processor\CallbackProcessor is a Cake Queue processor with return-is-data semantics: any return other than an explicit REJECT (or an explicit requeue Result) is success — the message is ACKed. Only a Throwable fails the message (requeue plus the failed() hook). Poison messages REJECT instead of killing the worker. Wire it like any processor:
'Queue' => ['ai' => [
// ...
'processor' => \Crustum\Queue\Processor\CallbackProcessor::class,
]],The middleware pipeline runs at its inner-execution point, so MiddlewareAwareInterface jobs get middleware here too.
then / catch payload callbacks travel inside the message and run wherever the job runs; a duck-typed failed(Throwable, array) hook fires on failure. Callback failures are logged and swallowed — callbacks observe, they never arbitrate the job result.
Packing (dispatch side) and unpacking (worker side) share one format definition in Processor\QueuedCallback (pack / unpack / unpackList, no global state). The default SerializableClosureSerializer needs laravel/serializable-closure (composer suggest, not required): without it packing throws a descriptive error and unpacking skips. Jobs opt into a custom serializer statically — no instance is constructed on either side:
use Crustum\Queue\Contract\CallbackSerializerInterface;
use Crustum\Queue\Contract\HasCallbackSerializer;
class MyJob implements JobInterface, HasCallbackSerializer
{
public static function callbackSerializer(): ?CallbackSerializerInterface
{
return new EncryptedSerializer('key');
}
// execute(...)
}Application-wide in-process execution without a worker. Default is off (async).
| Control | Role |
|---|---|
CrustumQueue.sync / CRUSTUM_QUEUE_SYNC |
Master switch — when false, SyncDispatchListener is not attached |
CrustumQueue.syncOnly |
Optional allow-list of job classes (empty = all eligible) |
SyncSuppressibleInterface |
Per-job hard opt-out |
Resolution order: suppress → global off → allow-list miss → sync.
Lifecycle under sync: pending → pushed (sync => true) → SyncJobRunner (Processor.message.*, app DI via ContainerRegistry). Reject / requeue / exceptions surface to the caller (no auto-reenqueue). Unique / delay / expires are no-ops in sync; delayed dispatch stays async. Other pending listeners (decorators, emitters) still run whether sync is on or off.
Do not catch SyncDispatchHandledException in application code — it is an internal signal absorbed by DispatchableTrait.
Every ::dispatch() always emits:
| Event | When |
|---|---|
Crustum/Queue.Job.pending |
Before accept (push or sync) |
Crustum/Queue.Job.pushed |
After accept — after push, or before sync execute (options.sync / $config['sync']) |
Event classes: JobPendingEvent, JobPushedEvent. Listen via CakePHP EventManager:
use Cake\Event\EventManager;
EventManager::instance()->on(
'Crustum/Queue.Job.pending',
function ($event): void {
// $event->getPayload(), getConnection(), getQueue(), …
},
);Default emission uses DefaultJobDispatchEmitter / EventDispatcher.
Register callbacks that run after tags/_uniqueId and before pending events + push/sync:
use Crustum\Queue\Event\JobDataMutators;
JobDataMutators::register(function (string $jobClass, array $data, array $config): array {
$data['speculum_uuid'] = $uuid;
return $data;
});JobDataMutators::clear() removes all mutators (tests).
Dispatch order: data mutators → plugin pending → application pending → (async push or sync: pushed then runner). On sync, application pending still runs before the trait catches the internal signal.
Register an additive application emitter for app-specific side effects. Plugin events still fire first:
use Crustum\Queue\Event\JobDispatchEmitterInterface;
use Crustum\Queue\Event\JobDispatchEmitters;
JobDispatchEmitters::set(new AppJobDispatchEmitter());JobDispatchEmitters::set(null) or clear() removes the application emitter only.
Implement JobDispatchEmitterInterface:
emitPending(string $jobClass, array $data, array $config): voidemitPushed(string $jobClass, array $data, array $config): voidbuildPayload(string $jobClass, array $data): array
Application emitters must not swallow sync control-flow exceptions if they wrap pending emission; JobDispatchEmitters rethrows SyncDispatchHandledException after the application pending hook.