Lesson 17 — Events, Listeners and Observers: TaskFlow announces successful task creation from its Action. We separate diagnostic logging from persistence and verify that a rolled-back transaction does not produce a misleading success log.
1. Business signals and model lifecycle signals
TaskCreated is explicitly dispatched by CreateTask. It describes a use case, not every insertion into the tasks table. Factories, seeders and direct Eloquent calls do not automatically pass through this Action. An observer instead reacts to model lifecycle events such as created or updated without knowing the caller's business intent.
Only the Action dispatches TaskCreated here. Dispatching it again from an observer could duplicate side effects. Rules that must cover every write path require controlled entry points and database constraints; an observer alone is not sufficient.
2. Implement an after-commit event
app/Events/TaskCreated.php
<?php
namespace App\Events;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
final readonly class TaskCreated implements ShouldDispatchAfterCommit
{
public function __construct(public int $taskId, public int $projectId) {}
}app/Listeners/LogTaskCreated.php
<?php
namespace App\Listeners;
use App\Events\TaskCreated;
use Illuminate\Support\Facades\Log;
class LogTaskCreated
{
public function handle(TaskCreated $event): void
{
Log::info('taskflow.task.created', ['task_id' => $event->taskId, 'project_id' => $event->projectId]);
}
}app/Actions/CreateTask.php
<?php
namespace App\Actions;
use App\Data\CreateTaskData;
use App\Events\TaskCreated;
use App\Models\Project;
use App\Models\Task;
use InvalidArgumentException;
final class CreateTask
{
// The caller must authorize access to the project before invoking this action.
public function handle(Project $project, CreateTaskData $data): Task
{
if (! $project->exists) {
throw new InvalidArgumentException('A persisted project is required.');
}
$task = $project->tasks()->create([
'title' => trim($data->title),
'description' => $data->description,
'priority' => $data->priority,
'status' => 'todo',
]);
event(new TaskCreated($task->id, $project->id));
return $task;
}
}The event carries task and project IDs rather than a Request, credentials, email or task description. The synchronous listener writes a stable event name and those IDs. This is diagnostic logging, not an immutable audit trail or a complete record of database changes.
ShouldDispatchAfterCommit delays dispatch within an active transaction until commit and discards the callback on rollback. Without a transaction, dispatch is immediate. The Action still performs one insert without starting its own transaction; a caller may wrap it in a larger unit of work. The tests below exercise that boundary.
3. Verify the actual listener wiring
php artisan event:list
php artisan test --filter=TaskCreatedEventTest
php artisan test
Laravel discovers LogTaskCreated in app/Listeners from the handle parameter type. Confirm that event:list shows TaskCreated mapped to LogTaskCreated@handle exactly once. Do not duplicate discovery with manual registration. If deployment uses an event cache, rebuild it for the new release so old wiring does not survive.
4. Test commit, rollback and an observer
tests/Feature/TaskCreatedEventTest.php
<?php
namespace Tests\Feature;
use App\Actions\CreateTask;
use App\Data\CreateTaskData;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Tests\TestCase;
class TaskCreatedEventTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
'database.connections.sqlite.url' => null]);
DB::purge('sqlite');
$this->artisan('migrate', ['--force' => true])->assertSuccessful();
}
public function test_discovered_listener_runs_only_after_commit(): void
{
$project = Project::factory()->create();
Log::spy();
$task = DB::transaction(function () use ($project) {
$task = app(CreateTask::class)->handle($project, new CreateTaskData('Commit me'));
Log::shouldNotHaveReceived('info');
return $task;
});
Log::shouldHaveReceived('info')->once()->with('taskflow.task.created', [
'task_id' => $task->id, 'project_id' => $project->id,
]);
}
public function test_rollback_discards_the_event_callback(): void
{
$project = Project::factory()->create();
Log::spy();
try {
DB::transaction(function () use ($project) {
app(CreateTask::class)->handle($project, new CreateTaskData('Rollback me'));
throw new RuntimeException('Rollback');
});
} catch (RuntimeException $exception) {
$this->assertSame('Rollback', $exception->getMessage());
}
Log::shouldNotHaveReceived('info');
$this->assertDatabaseCount('tasks', 0);
}
public function test_model_observer_does_not_receive_bulk_updates(): void
{
$observer = new class
{
public int $updates = 0;
public function updated(Task $task): void
{
$this->updates++;
}
};
$this->app->instance($observer::class, $observer);
Task::observe($observer);
$task = Task::factory()->create();
$task->update(['status' => 'doing']);
$this->assertSame(1, $observer->updates);
Task::query()->whereKey($task->id)->update(['status' => 'done']);
$this->assertSame('done', $task->fresh()->status);
$this->assertSame(1, $observer->updates);
}
}The first two tests use the real listener with Log::spy rather than faking the event dispatcher. They check both wiring and timing: no log inside the transaction, one after commit and none after rollback. Tests use in-memory SQLite without deleting application data. The milestone suite passed 43 tests with 143 assertions.
The third test registers an observer only in the test application. Binding its instance into the container lets the test inspect the same counter used by the callback. Updating a loaded model invokes updated; a bulk query update changes the record without invoking a per-model observer. Do not register this counting observer in the real application.
5. When does an observer fit?
Choose an observer when behavior genuinely belongs to the Eloquent lifecycle and grouping its methods improves clarity. A TaskObserver with updated(Task $task) can be registered using Task::observe(TaskObserver::class) in a provider's boot method. That is an alternative registration example, not an additional step required by TaskCreated.
An observer requiring after-commit handling can implement Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit. This differs from ShouldDispatchAfterCommit on the event. Avoid unconditionally saving the same model inside updated, which can recurse. Account for bulk updates/deletes, saveQuietly and withoutEvents when relying on model events.
6. After commit is not guaranteed delivery
The listener runs in the same process. If it throws after commit, committed data does not roll back; the caller may receive an error although the task exists. Retrying creation can then duplicate tasks. Process failure between commit and callback can also lose the side effect. The current log is not a durable delivery guarantee.
Design external notifications around queues, retries and idempotency. When losing an event is unacceptable, consider a transactional outbox: persist an event record in the business transaction, then forward it with a worker that tracks processing. The later queue lesson addresses asynchronous work; this milestone implements neither an outbox nor exactly-once delivery.
7. Exercises and completion criteria
Test immediate logging when the Action runs without a transaction. Create a task with a factory and explain why TaskCreated is absent. In an isolated test, make an after-commit listener throw and confirm the task remains persisted. Do not enable that deliberately failing listener in production.
References: Laravel Events and Eloquent Observers. Navigation: Lesson 16: Actions and DTOs · Roadmap. Next: exception handling and API errors.




No comments yet. Be the first to share your thoughts.