diff --git a/.ai/decisions.md b/.ai/decisions.md index e5ede43..ac148f6 100644 --- a/.ai/decisions.md +++ b/.ai/decisions.md @@ -93,3 +93,26 @@ Rejected as more tooling than the actual risk (single author, single codebase) justifies right now. --- + +## Status-change observer lives inline on `Application`, not a separate `Observer` class +_Recorded: 2026-09-03_ + +Laravel supports both a dedicated `Observer` class (registered via +`#[ObservedBy]` or in a service provider) and reacting to model events +directly inside the model's own `boot()`. The spec (`v1.md`) only specifies +the *effect* ("via an observer on `Application`"), not which of the two. + +**Why:** kept inline in `Application::boot()` rather than split into +`app/Observers/ApplicationObserver.php`. A separate Observer class is easy +to forget exists — it's registered elsewhere, invisible from the model +itself, and its file naturally goes unopened while working on the model. +Inline keeps the status-change side effect visible right next to `status`'s +own logic (`canTransitionTo()`), at the cost of a slightly larger model +class. + +**Alternative considered:** a dedicated `ApplicationObserver` class, +Laravel's more common convention for this. Rejected for now on the +forgettability argument above; revisit if `Application` accumulates enough +inline event handling to outweigh that. + +--- diff --git a/app/Models/Application.php b/app/Models/Application.php index 86565db..62c8416 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Enums\ApplicationStatus; +use App\Enums\InteractionType; use Carbon\CarbonImmutable; use Database\Factories\ApplicationFactory; use Illuminate\Database\Eloquent\Collection; @@ -41,6 +42,29 @@ final class Application extends Model 'notes', ]; + protected static function boot(): void + { + parent::boot(); + + self::updated(function (Application $application) { + if (! $application->wasChanged('status')) { + return; + } + + /** @var array $previous */ + $previous = $application->getPrevious(); + $application->interactions()->create([ + 'type' => InteractionType::STATUS_CHANGE, + 'occurred_at' => CarbonImmutable::now(), + 'body' => sprintf( + 'Status changed from %s to %s.', + $previous['status'], + $application->status->value, + ), + ]); + }); + } + /** @phpstan-return BelongsTo */ public function company(): BelongsTo { diff --git a/tests/Feature/ApplicationStatusChangeTest.php b/tests/Feature/ApplicationStatusChangeTest.php new file mode 100644 index 0000000..0c4783e --- /dev/null +++ b/tests/Feature/ApplicationStatusChangeTest.php @@ -0,0 +1,21 @@ +lead() + ->create(); + $application->update(['status' => 'applied']); + + /** @var Interaction $interaction */ + $interaction = $application->interactions()->first(); + + expect(Interaction::query()->count()) + ->toBe(1) + ->and($interaction->type->value) + ->toBe('status_change'); +});