Queues & Jobs
The dispatch() Helper (For Jobs)
Beside the event() helper, Dorguzen also has dispatch():
dispatch($job);
This is different from event().
🔹 event() is used for domain events.
It may:
- Run immediately
- Or create queued jobs (if listeners implement
ShouldQueue)
🔹 dispatch() on the other hand is used when you explicitly want to queue a job.
Example:
dispatch(new GenerateMonthlyReport($month));
This bypasses the Event layer entirely and goes straight to the:
- QueueManager → which uses the active queue driver set in config (
'sync'or'db') to know how to queue the job for processing.
Here is an example of what the queue setting in your .env (config) file looks like:
# Queueing. Options: sync, db
# If the driver is db, you need an existing worker loop (CLI command), which uses the jobs table
QUEUE_DRIVER=db
When To Use Each?
| Use Case | Helper |
|---|---|
| Something happened in your domain | event() |
| You want to queue specific work | dispatch() |
Important Distinction
Not all events become jobs.
Only listeners implementing ShouldQueue do.
But all jobs dispatched with dispatch() are jobs immediately.
How Database Queue Works
When using DatabaseQueue driver:
'queue.default' => 'database'
When a Job is Pushed
DatabaseQueue->push($job);
It inserts into the dgz_jobs table:
INSERT INTO dgz_jobs (
queue,
payload,
attempts,
max_attempts,
available_at,
created_at
)
VALUES (...)
Where the payload value will be typically the listener class serialized e.g.
serialize($job)
as well as other meta data about the event.
attempts will be like 3 or however maximum number of times you would like your queue worker to attempt to consume the job before permanently marking it as failed.
How Jobs Are Processed (Worker)
Dorguzen has a queue worker daemon which you can fire up to run permanently, waiting to consume jobs. Start it using the following command:
php dgz queue:work
Internally, this command grabs all existing jobs (event listener classes) from the dgz_jobs table, one by one, unserializes them, and consumes them by running their handle() method which all event listener classes must have.
while (true) {
$job = $queue->pop();
if ($job) {
$job->handle();
}
}
Handling Race Conditions (Very Important)
This is critical for production systems.
Because multiple workers may be running at the same time, the Dorguzen queue worker in order to prevent workers processing the same job more than once; it implements some atomic DB operations that involve locking of the database row to mark that job as already being processed so other queue workers will skip it, and then marking the job as completed when its done by updating a status flag on the table. The field that is used in this locking is the reserved_at field on the same dgz_jobs table.
Typical safe pattern: Before any queue worker grabs a job to process, it makes sure it only grabs those whose reserved_at field has a value of NULL.
SELECT * FROM jobs
WHERE reserved_at IS NULL
ORDER BY id
LIMIT 1
FOR UPDATE
Then when it is done processing each job, it sets the flag by updating that same reserved_at field to the current timestamp e.g:
UPDATE jobs
SET reserved_at = NOW()
WHERE id = ?
This ensures:
- ✔ Only one worker reserves a job
- ✔ Other workers skip it
- ✔ No double processing
This is called pessimistic locking.
SyncQueue vs DatabaseQueue
Let us look at the characteristics of the two types of queues Dorguzen uses; SyncQueue and DatabaseQueue.
SyncQueue
- No DB
- No workers
- Immediate execution
- Useful for development
DatabaseQueue
- Persistent storage
- Background workers
- Safe concurrent processing
- Retry capability
- Scalable
Full Lifecycle Summary
Developer calls event()
↓
EventDispatcher
↓
Resolve listeners
↓
If NOT ShouldQueue:
→ handle immediately
If ShouldQueue:
↓
Create QueuedJob
↓
QueueManager
↓
SyncQueue OR DatabaseQueue
↓
(If database)
Stored in jobs table
↓
Queue Worker
↓
pop()
↓
handle()
Why This Design Is Powerful
- ✔ Domain logic stays clean
- ✔ Async behavior is opt-in via interface
- ✔ Drivers are swappable
- ✔ Safe concurrent job processing
- ✔ Clear separation of responsibilities
Final Developer take-away points
If I'm building on Dorguzen:
- I create events to describe domain occurrences
- I attach listeners to react to those occurrences
- I implement
ShouldQueuewhen work should be async - I use
dispatch()when I want direct job execution - I rely on Dorguzen to:
- Serialize jobs
- Store them
- Prevent race conditions
- Process them safely via workers
The Jobs processing lifecycle
The Dorguzen Job Processing Lifecycle (Deep Explanation)
Before anything else, ensure your application has these two tables:
dgz_jobsdgz_failed_jobs
Run migrations if they do not exist. Dorguzen ships with the migration files for these tables, so running your migrations will set them up for you.
These two tables are the foundation of asynchronous processing in Dorguzen.
Events vs Jobs (Re-stated for Clarity)
This distinction is extremely important.
Events
Live in: src/events/
Represent something that happened. They may or may not be queued. They only become queued if their listener implements ShouldQueue.
Jobs
Live in: src/jobs/
They always use the queue system. They are explicitly dispatched using dispatch().
They never run outside the queue system.
So remember:
All Jobs are queued. Not all Events are queued.
An Event only enters the queue system if its listener implements:
implement Dorguzen\Core\Events\ShouldQueue;
How an Event Reaches the Queue System
Let's repeat this carefully. A developer calls:
event(new UserRegistered($user));
EventService passes it to EventDispatcher. EventDispatcher resolves listeners using ListenerResolver.
For each listener:
-
if it does NOT implement
ShouldQueue:- run its
handle()method immediately
- run its
-
If it DOES implement
ShouldQueue:- it is wrapped into a QueuedListener
- and passed to
QueueManager->push()
This is the exact bridge between Events and the Queue system.
Only here does the Event system hand over control to the Queue.
The Queue System (Deep Internal Explanation)
Now we move fully into the Queue system.
The queue system consists of:
- QueueManager
- QueuedJob
- Drivers:
- QueueInterface
- SyncQueue
- DatabaseQueue
The active driver is controlled by the configuration setting:
'queue_driver'
which can either be
'sync' or 'db'
DatabaseQueue — The Asynchronous Engine
This is where things become serious. DatabaseQueue stores jobs inside:
- the
dgz_jobstable
Here is the structure of the dgz_jobs table:
id
queue
payload
attempts
max_attempts
reserved_at
available_at
failed_at
created_at
reserved_at
When a Job Is Pushed
That means the job is queued; it is initiated from Dorguzen\Core\Queues\QueueManager, which checks for the active queue driver in configs
'queue_driver' which can either be SyncQueue or DatabaseQueue, and the signature of the push() method in both drivers looks like this:
public function push(object $job, ?int $delaySeconds = null)
As mentioned, if the active queue driver is DatabaseQueue, it inserts into the database:
$this->db->insert('dgz_jobs', [
'queue' => 'default',
'payload' => serialize($job),
'attempts' => 0,
'max_attempts' => 3,
'available_at' => date(...),
'created_at' => date(...),
]);
Important details:
payloadis serialized.attemptsstarts at 0. Theattemptsfield represents how many times (tries) workers tried to process the jobmax_attemptsdefaults to 3.available_atcontrols when the job becomes eligible for processing.reserved_atis NULL initially.
The Worker Process
The worker runs in CLI:
php dgz queue:work
Internally, it is a daemon that runs continuously once started:
while (true) {
$job = $queue->pop();
if ($job) {
try {
$job->handle();
$queue->acknowledge($job);
} catch (Throwable $e) {
$queue->release($job);
}
}
}
This is the full lifecycle.
pop() — is how a driver safely claims a job from the database.
This is the most important part of your entire system.
Let's walk through it carefully.
Step 1 — Find Available Job
SELECT * FROM dgz_jobs
WHERE reserved_at IS NULL
AND available_at <= NOW()
ORDER BY id ASC
LIMIT 1
This SQL query means:
- get a job that is not already reserved (
reserved_at IS NULL). This is the locking mechanism that ensures that two queue workers do not process the same job. - The job is due for processing, and not delayed (
<= NOW()) - get the oldest job first in FIFO style (
ORDER BY id ASC)
NULL will be returned if none found.
Step 2 — Attempt to Reserve It (Race Condition Protection)
Now comes the critical part. After a job is processed, this is done:
UPDATE dgz_jobs
SET reserved_at = NOW(),
attempts = attempts + 1
WHERE id = ?
AND reserved_at IS NULL
Notice this:
AND reserved_at IS NULL
This is optimistic locking. Why? Because multiple workers may:
Read the same job, and therefore try to reserve it simultaneously. This lock mechanism ensures that only ONE worker will successfully update the row, preventing double execution. The other queue workers running will skip a row that's already taken by another worker:
- Affecting 0 rows
- Returning null
- Will skip processing of that row
This is your race-condition safety mechanism.
Attempts and Retries Explained
When a job is first inserted:
attempts = 0
max_attempts = 3
When a worker reserves it, it increments the attempts number by 1:
attempts = attempts + 1
So on first execution attempt, attempts will be equal to 1:
attempts = 1
What Happens If Job Succeeds?
The worker marks the job as done. It does so by running this line:
$queue->acknowledge($job);
Which basically removes the Job from the dgz_jobs database table, effectively removing it from the queue.
DELETE FROM dgz_jobs WHERE id = ?
What Happens If Job Fails?
If an exception is thrown:
catch (Throwable $e) {
$queue->release($job);
}
Now we enter retry logic.
release() — Retry Logic
When grabbing jobs to process, the system checks if the number of tries has not exceeded the maximum number of attempts.
if ($job->attempts >= $job->maxAttempts)
If the attempts have reached the max:
- the job is moved to the
dgz_failed_jobstable. - and then deleted from
dgz_jobs
This is poison-job protection.
If the attempts have not reached the max, it is marked as taken (reserved_at), then processed, and then marked as released again (reserved_at = NULL), with its attempts value incremented by 1:
UPDATE dgz_jobs
SET reserved_at = NULL,
attempts = ?,
available_at = future_time
WHERE id = ?
What happens here?
reserved_atbecomes NULL → job becomes available againattemptsstays incrementedavailable_atis pushed into future (default 5 seconds)
This prevents immediate hammering retries.
fail() — Permanent Failure Handling
When job has exceeded its max attempts and the processing was unsuccessful, it is saved into dgz_failed_jobs and removed from dgz_jobs:
$this->db->insert('dgz_failed_jobs', [
'queue' => ...,
'payload' => serialize(...),
'exception' => message,
'exception_trace' => trace,
'attempts' => ...,
'failed_at' => ...
]);
And:
DELETE FROM dgz_jobs WHERE id = ?
So:
- ✔ Job is removed from active queue
- ✔ Failure is recorded permanently
- ✔ Debugging information preserved
How the 'available_at' field delays execution
This field has two roles:
- Delay execution when first pushed.
- Delay retries.
A job will not be picked up unless:
available_atis less than, or equal to the current timestamp (available_at <= NOW())
This is how Dorguzen implements:
- Delayed jobs
- Retry backoff
- Scheduled execution timing
The 'reserved_at' — Locking Mechanism
This field is critical. As a reminder, here are things to know:
When its value is NULL, it means:
- The Job is free
When its value is the current timestamp, it means:
- The Job is currently being processed
Workers only pick jobs where:
reserved_at IS NULL
This prevents duplicate execution.
Summary of Full Lifecycle
Let's describe the full journey.
Step 1 — Job Inserted
attempts = 0
reserved_at = NULL
available_at = NOW
Step 2 — Worker Finds Job
Queue workers check if:
reserved_at IS NULL
available_at <= NOW
Step 3 — Worker Claims matching Job, runs it and updates it like so:
reserved_at = NOW
attempts = attempts + 1
Only one worker succeeds.
Step 4 — Job Runs
If it succeeds:
- it does
acknowledge()(which basically means it deletes it from the queue)
If it fails:
- it does a
release()from the queue (which basically means it inserts it into thedgz_failed_jobstable and deletes it from thedgz_jobstable)
Step 5 — Retry or Fail Permanently
If attempts < max_attempts - repeat the cycle:
reserved_at = NULL
available_at = NOW + delay
If attempts >= max_attempts:
INSERT INTO dgz_failed_jobs
DELETE FROM dgz_jobs
Poison Job Protection
A poison job is a job that will never pass processing, either because there could be an error in the process. It will therefore wear on your system to keep using resources in trying to process it endlessly. The maximum attempts limitation protects your system from infinite loops.
Without this:
- A broken job could retry forever
- your queue will be overloaded
- It will cause CPU exhaustion
That is why Dorguzen stops the processing after max_attempts. You can adjust this attempt figure.
Summary of the features of the Dorguzen queue system
- It is driver-based
- It supports sync and database
- Protects against race conditions
- Supports retries
- Supports delayed jobs
- Records permanent failures
- Safely deletes successful jobs
- And most importantly; the developer does not need to manage any of this manually.
They simply run:
dispatch(new ProcessReport());
// or
queue(new ProcessReport());
Or:
event(new UserRegistered());
And Dorguzen handles:
- Serialization
- Locking
- Retrying
- Backoff
- Failure storage
- Concurrency protection etc
Writing Custom Job Classes in Dorguzen
Remember:
Jobs live in src/jobs/
Jobs ALWAYS use the queue system.
Jobs are dispatched using the dispatch() helper.
Jobs must contain a handle() method.
A Job is simply a class whose handle() method contains the logic you want to run asynchronously.
Generating a Job (make:job)
Rather than hand-writing the file, you can scaffold a Job class with the CLI:
php dgz make:job SendInvoice
This generates a ready-to-edit class (the Job suffix is appended automatically if you omit it) with a stubbed handle() method. Just fill in your logic:
namespace Dorguzen\Jobs;
class SendInvoiceJob
{
public function handle(): void
{
// TODO: Implement Job logic
}
}
Example 1 — A Job That Runs Immediately
Let's create:
src/jobs/LogSomethingJob.php
namespace Dorguzen\Jobs;
class LogSomethingJob
{
public function handle(): void
{
file_put_contents(
storage_path('logs/test.log'),
"Job ran at: " . date('Y-m-d H:i:s') . PHP_EOL,
FILE_APPEND
);
echo "LogSomethingJob executed successfully.\n";
}
}
Now dispatch it:
dispatch(new \Dorguzen\Jobs\LogSomethingJob());
What Happens Internally?
If your queue_driver is:
'queue_driver' => 'sync'
- → The job runs immediately
- →
handle()executes instantly - → No database entry created
If your queue_driver is:
'queue_driver' => 'db'
- → Job is inserted into
dgz_jobs - → Worker must process it
Example 2 — A Job That Runs in 5 Minutes
namespace Dorguzen\Jobs;
class DelayedEmailJob
{
public function handle(): void
{
echo "Delayed job executed at: " . date('Y-m-d H:i:s') . "\n";
}
}
Dispatch it with delay:
dispatch(new \Dorguzen\Jobs\DelayedEmailJob(), 300);
(300 seconds = 5 minutes)
What Happens?
DatabaseQueue->push() sets:
'available_at' => NOW + 300 seconds
The worker will ignore this job until:
available_at <= current time
Even if worker is running, it will skip it until eligible.
Example 3 — A Job That Fails (To Test Retries)
namespace Dorguzen\Jobs;
class FailingJob
{
public function handle(): void
{
echo "FailingJob running...\n";
throw new \Exception("This job failed intentionally.");
}
}
Dispatch:
dispatch(new \Dorguzen\Jobs\FailingJob());
What Happens When Worker Runs?
Let's assume:
max_attempts = 3
Attempt 1:
- Worker reserves job
attemptsbecomes 1handle()throws exceptionrelease()calledreserved_atset to NULLavailable_atset to NOW + 5 seconds
Attempt 2:
attemptsbecomes 2- fails again
- delayed again
Attempt 3:
attemptsbecomes 3- fails again
release()seesattempts >= max_attemptsfail()is triggered
Now:
- ✔ Job inserted into
dgz_failed_jobs - ✔ Job removed from
dgz_jobs - ✔ Exception message + trace stored
This is poison job protection.
Running the Queue Worker
To process queued jobs:
php dgz queue:work
This starts a long-running CLI process.
The command also accepts several aliases — q:work, qw, queue:consume, q:consume — and the following options:
| Option | Description |
|---|---|
--once |
Process a single job, then exit |
--sleep=N |
Seconds to sleep when no job is available (default 1) |
--max-jobs=N |
Exit after processing N jobs |
--timeout=N |
Max execution time per job, in seconds (default 60) |
php dgz queue:work --once
php dgz queue:work --sleep=5
php dgz queue:work --max-jobs=10
php dgz queue:work --timeout=300
What Happens When You Run queue:work?
Internally, the worker:
- Continuously calls
pop()
If a job is returned:
- Runs
$job->handle() - If success →
acknowledge() - If failure →
release()
If no job:
- Prints: "No jobs in the queue"
- Sleeps briefly
- Loops again
What Developers Will See in Testing
If they dispatch:
dispatch(new FailingJob());
Then run:
php dgz queue:work
They will see:
FailingJob running...
FailingJob running...
FailingJob running...
Then it disappears from active queue and appears in:
dgz_failed_jobs
Queue Workers — Deep Production Explanation
Now we document how workers behave in real environments.
This is important.
1️⃣ Queue Workers Are Long-Running Processes
When you run:
php dgz queue:work
It does NOT exit.
It runs continuously.
It is meant to:
- Stay alive
- Process jobs as they arrive
- Keep looping forever
In production, this is usually managed by:
- Supervisor (Linux)
- Systemd
- Docker container
- PM2
- Kubernetes
2️⃣ Why It Does Not Drain Resources
Your worker does something important:
When no job is available:
sleep(1);
(or similar)
This means:
- CPU usage drops to near zero
- It waits calmly
- It wakes up periodically to check again
It does NOT busy-loop aggressively.
That makes it safe for long-term use.
3️⃣ Real-Life Production Setup
In production, you typically:
- Set
queue_driverto'db' - Start worker in background
- Let it run forever
Example using Supervisor:
[program:dorguzen-worker]
command=php /path/to/project/dgz queue:work
autostart=true
autorestart=true
Now:
- If worker crashes → it restarts automatically
- If server reboots → worker starts again
4️⃣ Graceful Termination
To stop the worker manually:
Press:
CTRL + C
This sends a SIGINT. When the pcntl extension is available, the worker traps both SIGTERM and SIGINT and shuts down gracefully: instead of dying mid-job, it finishes the job currently in flight, then exits cleanly (Queue worker stopped gracefully.). This prevents a job being left half-processed.
In production:
supervisorctl stop dorguzen-worker
Or:
kill <process_id>
5️⃣ Multiple Workers
Because of optimistic locking:
WHERE reserved_at IS NULL
You can safely run:
php dgz queue:work
php dgz queue:work
php dgz queue:work
Multiple workers can run simultaneously.
They will not process the same job twice.
This allows horizontal scaling.
6️⃣ Why Workers Must Be Long Running
Spawning a new PHP process per job would:
- Be slow
- Be inefficient
- Increase memory churn
Instead:
Dorguzen keeps one process alive and:
- Loads framework once
- Reuses memory
- Loops efficiently
This is professional-grade architecture.
Managing the Queue (CLI Commands)
Beyond queue:work, Dorguzen ships a set of CLI commands for inspecting and managing the queue and its failures:
php dgz queue:jobs # list jobs currently in the queue
php dgz queue:stats # show queue statistics (pending / failed counts)
php dgz queue:failed # list all failed jobs
php dgz queue:retry <id> # retry a single failed job by id
php dgz queue:retry all # retry every failed job
php dgz queue:forget <id> # permanently delete a failed job
php dgz queue:removejob <id> # remove a job from the active queue
php dgz queue:clear # clear all pending jobs
queue:retry accepts either a specific failed-job id or the literal all to requeue every failed job at once. queue:forget permanently removes a single record from dgz_failed_jobs, while queue:clear empties the active dgz_jobs queue.
Full Mental Model for Developers
When a developer writes:
dispatch(new SendInvoiceJob($invoice));
They should understand:
- Job serialized
- Inserted into
dgz_jobs - Worker finds it
- Worker locks it
- Worker executes it
- On success → deleted
- On failure → retried
- On max failure → moved to
dgz_failed_jobs
All automatically.
Final Note for Documentation
You can confidently state:
Dorguzen provides a production-ready queue system with:
- Delayed jobs
- Retry logic
- Failure logging
- Concurrency safety
- Poison job protection
- Long-running workers
- Horizontal scalability
And the developer only needs to:
dispatch(new MyJob());