Events
This tells you how your programming language helps you respond to events. This brings to mind design patterns like the Subscriber pattern (pub-sub), the dispatching of jobs based on the triggering of certain events and the consumption of such, within your application.
Note here that the Command topic often has a great role to play here.
Events handling in Dorguzen
Event vs Job — The Conceptual Difference
This is the most important distinction.
🔹 What is an Event?
An Event represents something that has happened in your application.
Examples:
UserRegisteredOrderPlacedPasswordResetRequested
An event answers the question: "What just happened?" It does not describe work to be done. It simply describes an occurrence.
🔹 What is a Job?
A Job represents work that needs to be processed.
For example:
- Send an email
- Resize an image
- Generate a report
- Process a payment
A job answers the question: "What needs to be executed?"
When Does an Event Become a Job?
An event never becomes a job by itself. Instead, an event is dispatched.
Its listeners are resolved, and if a listener implements ShouldQueue, that listener is wrapped into a QueuedJob.
The QueuedJob is then pushed into a queue. So the flow of the transformation goes like this:
Event
↓
Listener
↓ (if implements ShouldQueue)
QueuedJob
Only the listener, not the event itself, becomes a queued job. That distinction is very important.
Creating a Custom Event (ProductPurchased Example)
Let's build a completely custom event from scratch. We will use ProductPurchased as the example — a domain event that fires whenever a user completes a purchase.
(Note: Dorguzen also ships with core events such as UserRegistered, UserLoggedIn, etc. that fire automatically. This section shows how to create your own on top of those.)
You can scaffold the files with the CLI:
php dgz make:event ProductPurchased
php dgz make:listener NotifyAdminOfPurchase
php dgz make:listener SendPurchaseReceiptEmail
Then fill them in:
Step 1 — Create the Event
File: src/events/ProductPurchased.php
namespace Dorguzen\Events;
class ProductPurchased
{
public function __construct(
public int $userId,
public string $userEmail,
public int $productId,
public string $productName,
public float $amount,
) {}
}
Simple. It is just a data container — no methods, just the facts about what happened.
Step 2 — Create Listeners
Listener A — synchronous (runs immediately, before the response is sent)
File: src/listeners/NotifyAdminOfPurchase.php
namespace Dorguzen\Listeners;
use Dorguzen\Events\ProductPurchased;
use Dorguzen\Core\DGZ_Logger;
class NotifyAdminOfPurchase
{
public function handle(ProductPurchased $event): void
{
DGZ_Logger::info('Purchase completed', [
'user_id' => $event->userId,
'product_id' => $event->productId,
'product_name' => $event->productName,
'amount' => $event->amount,
]);
// could also send a Slack notification here via DGZ_Slack::send()
}
}
This runs immediately and completes before the HTTP response is returned to the user.
Listener B — queued (runs in the background via the queue worker)
File: src/listeners/SendPurchaseReceiptEmail.php
namespace Dorguzen\Listeners;
use Dorguzen\Core\Events\ShouldQueue;
use Dorguzen\Events\ProductPurchased;
use Dorguzen\Core\DGZ_Messenger;
class SendPurchaseReceiptEmail implements ShouldQueue
{
public function handle(ProductPurchased $event): void
{
$messenger = new DGZ_Messenger();
$messenger->sendEmail(
$event->userEmail,
"Your purchase of {$event->productName}",
"Thank you! Your payment of {$event->amount} was received."
);
}
}
Because it implements ShouldQueue, this listener is not called inline. Instead, EventDispatcher hands it to QueueManager, which stores the job and returns immediately. The queue worker processes it in the background.
Step 3 — Register in configs/events.php
return [
\Dorguzen\Events\ProductPurchased::class => [
\Dorguzen\Listeners\NotifyAdminOfPurchase::class, // runs now
\Dorguzen\Listeners\SendPurchaseReceiptEmail::class, // runs in queue
],
];
You can add as many listeners as you like. They fire in the order listed. Synchronous listeners run first, then queued ones are handed off.
Step 4 — Dispatch the Event
Inside your controller, after a successful purchase:
event(new ProductPurchased(
userId: (int) $user->users_id,
userEmail: $user->users_email,
productId: (int) $product['product_id'],
productName: $product['product_name'],
amount: (float) $order['total_amount'],
));
What Happens Internally
Let's trace the exact flow.
Step A — event() Helper
The helper:
use Dorguzen\Core\Events\EventService;
/**
* It is recommended to use event() to dispatch Events, as Events
* are meant to pass through the EventService class pipeline.
*
* Events are not candidates for the queueing system, though an Event may be
* marked for queueing by making its listener implement the ShouldQueue interface
* (core/events/ShouldQueue.php), in which case, event() will detect that internally
* and hand the Event over to the queue system to be dispatched as a job.
*
* Example usage:
* event(new TestEvent('hello'));
*/
if (!function_exists('event')) {
function event(object $event): void
{
container(EventService::class)->dispatch($event);
}
}
It sends the event into the system.
Step B — EventDispatcher
The EventService (in core/events/EventService.php) class sends the event into the system by passing the raised event to the dispatch() method of the EventDispatcher class.
This EventDispatcher->dispatch() resolves all registered listeners of that event from config, loops through them and checks
if a $listener is an instanceof ShouldQueue, and places it in a queue if it does, otherwise it runs the listener immediately by calling its handle() method which all listener classes must have.
Step C — Immediate Listener
$listener->handle($event);
Step D — Queued listener
If the $listener is an instance (implements) ShouldQueue, then the listener will be placed in a queue instead of being run:
$job = new QueuedJob($listenerClass, $event);
$queueManager->push($job);
Now we are officially in Job territory.
The event has triggered a job.
Once more, in summary; EventService is the Dorguzen entry point for events. The flow goes like this:
EventServicedispatches a service toEventDispatcher,EventDispatcherusesListenerResolverto resolve listeners from the configuration (the central glue),EventDispatcher, after resolving a listener class, checks if the listener implementsShouldQueue. If the listener does not implementShouldQueue, it runs that listener directly by calling thehandle()method of the listener to handle the event. If the listener implementsShouldQueue, then it knows that the listener needs to become a queued job, so it packages the event listener into aQueuedListener. See this as a way of standardising the format of the listener as a queued object, for easy consumption later. It then passes thisQueuedListenerto thepush()method ofQueueManager, which uses the currently active queue type (based on thequeue_driversetting) to queue the event. This is how an event reaches the queue system, and theEventDispatcherclass is the bridge. So, again, only events whose listeners implementShouldQueuewill hit the queue system. Jobs, naturally, unlike events, only use the queue system.
Built-in Dorguzen Core Events
Event classes as data containers — how hydration works
If you open any event class (e.g. src/events/UserRegistered.php) you will notice it has no methods. It is a plain PHP class whose only job is to hold data. Think of it as a sealed envelope — it carries information about what just happened, and every listener that receives it can read that information.
The envelope is filled (hydrated) at the exact moment the event is dispatched. In Dorguzen, that happens inside a controller like this:
$activationUrl = $this->config->getHomePage() . 'auth/verifyEmail?em=' . $activationCode;
event(new UserRegistered((int) $saved, $username, $email, $firstname, $activationCode, $activationUrl));
Breaking that line down:
-
new UserRegistered(...)— Creates the event object and passes real values into its constructor. PHP's constructor property promotion assigns them directly to the public properties ($userId,$username,$email,$firstname,$activationCode,$activationUrl). This is the hydration step — the object is now fully populated. -
event(...)— Passes the hydrated object into theEventServicepipeline.EventServiceresolves all listeners registered for this event inconfigs/events.phpand callshandle()on each one, passing the same event object in.
By the time handle() runs in any listener, the event is already loaded:
public function handle(UserRegistered $event): void
{
// $event->userId, $event->username, $event->email,
// $event->firstname are all available and populated.
DGZ_Logger::info('User registered', [
'user_id' => $event->userId,
'username' => $event->username,
]);
}
The full flow from dispatch to listener:
new UserRegistered($id, $username, $email, $firstname)
↓ hydrated here — constructor properties set
event() helper
↓
EventService → EventDispatcher
↓ resolves listeners from configs/events.php
LogUserRegistration::handle($event) ← same fully-loaded object
SendWelcomeEmail::handle($event) ← same fully-loaded object
Every listener in the chain receives the same object. None of them need to do a database lookup to find the user's name or email — it is already sitting in the event properties, placed there at dispatch time.
This is why event constructors are deliberate about what data they carry: too little and listeners have to query the database themselves (slow, couples listeners to models); too much and the event becomes a dumping ground. The goal is to carry exactly the data that listeners are most likely to need.
Why built-in events matter
A framework that ships with no events forces every developer to wire up the same lifecycle hooks from scratch on every project. Dorguzen ships with a set of core events that fire automatically at the most common and important moments in any web application — user registration, login, logout, newsletter subscription, and contact form submission.
This means that from day one, a Dorguzen developer can react to any of these moments simply by registering a listener in configs/events.php. No changes to controllers, no boilerplate — just add the listener and it runs automatically.
The five core events
All core events live in src/events/ and are dispatched automatically by the framework. All carry only the data that is directly relevant to the occurrence — no database lookups needed inside listeners.
UserRegistered
src/events/UserRegistered.php
Fired after a new user account is successfully created, whether via the web registration form (AuthController::doRegis()) or the API (AuthApiController::register()).
Payload:
$event->userId int The new user's database ID
$event->username string Their chosen username
$event->email string Their email address
$event->firstname string Their first name
$event->activationCode string The MD5 hash stored in users_eactivationcode
(empty string if your app does not use email
verification)
$event->activationUrl string The full verification URL, e.g.
https://yourapp.com/auth/verifyEmail?em=<code>
Pre-built by the controller so listeners do not
need to know how URLs are constructed.
(empty string if your app does not use email
verification)
The controller builds $activationUrl before dispatching:
$activationUrl = $this->config->getHomePage() . 'auth/verifyEmail?em=' . $activationCode;
event(new UserRegistered((int) $saved, $username, $email, $firstname, $activationCode, $activationUrl));
The built-in SendWelcomeEmail listener reads $event->activationUrl and sends the activation email. This keeps the email-sending logic out of the controller entirely — doRegis() fires the event and moves on.
Example uses: send an account activation email (→ SendWelcomeEmail), log the registration (→ LogUserRegistration), notify a CRM.
UserLoggedIn
src/events/UserLoggedIn.php
Fired after a user successfully authenticates, whether via the web login form (AuthController::doLogin()) or the API (AuthApiController::login()).
Payload:
$event->userId int The user's database ID
$event->username string Their username
$event->userType string Their account type (member, admin, etc.)
Example uses: log login activity, detect logins from new locations, update a "last_login" timestamp.
UserLoggedOut
src/events/UserLoggedOut.php
Fired just before the user's session is destroyed in AuthController::logout(). It is dispatched before session_destroy() so that the user's identity is still available to listeners.
Payload:
$event->userId int The user's database ID
$event->username string Their username
Example uses: log the logout for an audit trail, record session duration, invalidate server-side caches tied to that user.
UserSubscribed
src/events/UserSubscribed.php
Fired after a visitor successfully subscribes to the newsletter via FeedbackController::subscribe().
Payload:
$event->name string The subscriber's name
$event->email string Their email address
Example uses: send a subscription confirmation email, sync to a mailing list provider (Mailchimp, etc.), log the subscription for analytics.
Note on the shipped SendSubscriptionConfirmation listener:
The listener that ships with this event has an empty handle() body by design. Whether to send an instant confirmation email on sign-up is an application-level decision — some apps do; others defer it (e.g. batch-send via a cron job after admin review). If your app wants instant confirmation, add the sending logic directly inside SendSubscriptionConfirmation::handle().
ContactFormSubmitted
src/events/ContactFormSubmitted.php
Fired after a visitor successfully submits the contact form via FeedbackController::processContact().
Note: the admin notification email is already sent synchronously by the controller. This event is for any additional reactions on top of that — the controller handles the admin side, the event handles everything else.
Payload:
$event->name string The visitor's name
$event->email string Their email address
$event->phone string Their phone number (may be empty)
$event->message string The message body
Example uses: send a receipt/confirmation email to the visitor, log the submission to an analytics or CRM system, trigger a follow-up workflow.
Built-in listeners shipped with each event
Each core event ships with one or more pre-wired listeners in src/listeners/. These work out of the box and serve as concrete examples to build on:
| Event | Listener(s) |
|---|---|
| UserRegistered | SendWelcomeEmail, LogUserRegistration |
| UserLoggedIn | LogUserLogin |
| UserLoggedOut | LogUserLogout |
| UserSubscribed | SendSubscriptionConfirmation |
| ContactFormSubmitted | SendContactConfirmation |
All mappings live in configs/events.php. That file is the single source of truth for which listeners respond to which events.
Adding your own listener to a core event
To react to a core event, you do not touch any controller. You only need two things:
Step 1 — Create your listener in src/listeners/
// src/listeners/SyncNewUserToCrm.php
namespace Dorguzen\Listeners;
use Dorguzen\Events\UserRegistered;
class SyncNewUserToCrm
{
public function handle(UserRegistered $event): void
{
// push $event->email and $event->username to your CRM API
}
}
Step 2 — Register it in configs/events.php
\Dorguzen\Events\UserRegistered::class => [
\Dorguzen\Listeners\SendWelcomeEmail::class,
\Dorguzen\Listeners\LogUserRegistration::class,
\Dorguzen\Listeners\SyncNewUserToCrm::class, // ← add here
],
That's it. The next time a user registers, all three listeners fire in the order listed.
Logging inside listeners — use DGZ_Logger
When a listener needs to write to the log, always use DGZ_Logger rather than accessing the Logs model directly. DGZ_Logger is the correct tool because it:
- Respects the
APP_LOG_DRIVERsetting in.env(file, db, or both) - Applies the correct log format (
APP_LOG_FORMAT) - Supports named channels for routing specific events to dedicated logs
Basic usage (logs to whatever driver .env specifies):
use Dorguzen\Core\DGZ_Logger;
DGZ_Logger::info('User registered', [
'user_id' => $event->userId,
'username' => $event->username,
]);
Routing to a dedicated channel (e.g. a separate auth log):
DGZ_Logger::channel('auth')->info('User logged in', [
'user_id' => $event->userId,
'username' => $event->username,
]);
Available severity levels: debug, info, warning, error.
Using the Logs model directly (container(Logs::class)->log(...)) bypasses all of this and always writes to the database regardless of your .env settings — avoid it inside listeners.
Making a listener asynchronous (ShouldQueue)
By default all listeners run synchronously — they complete before the HTTP response is sent. For slow operations like sending emails or calling external APIs, implement ShouldQueue to push the listener onto the queue instead:
use Dorguzen\Core\Events\ShouldQueue;
class SendWelcomeEmail implements ShouldQueue
{
public function handle(UserRegistered $event): void
{
// this now runs in the background via the queue worker
}
}
The queue worker must be running for queued listeners to execute:
php dgz queue:work
See the Queues & Jobs section of these docs for full details on queue drivers, workers, and monitoring.