This is a hand-built, step-by-step walkthrough of building a real LaraFly feature from an empty
firefly new application to a fully attribute-driven slice — a REST controller, a service, typed
configuration, a repository-backed domain model, request validation, RFC-7807 error handling, a CQRS
command/query pair, a domain event listener, a database-agnostic outbox, a compensating saga, and an event-sourced journal. Every
code block below is accurate to the framework as shipped in this repository — attributes, class names, and
method signatures are taken verbatim from firefly/* package sources and the runnable samples/lumen
wallet sample, not invented for the tutorial.
By the end you will have grown the firefly/skeleton starter's sample Greeting slice into a small
"greetings" feature with a persisted greetings table, an #[EventListener] reacting to a domain event
delivered through a database outbox, a greeting-card saga that undoes itself, an event-sourced greeting journal
projected into a read model, and a zero-reflection cached boot — the same architecture the
Lumen sample uses at larger scale.
- Step 1: Create the App
- Step 2: Your First Endpoint —
#[RestController] - Step 3: Autowiring a Service —
#[Service] - Step 4: Typed Configuration —
#[ConfigProperties] - Step 5: Run It and Call the API
- Step 6: Persisting Greetings —
#[Repository]and Derived Queries - Step 7: Validating the Request Body —
#[Valid] - Step 8: RFC-7807 Error Handling
- Step 9: Commands and Queries — CQRS
- Step 10: Reacting to a Domain Event —
#[EventListener] - Step 11: The Zero-Reflection Cache and Health Introspection
- Step 12: A Database-Agnostic Outbox and a Consumer
- Step 13: A Saga with Compensation —
#[Saga] - Step 14: Event-Sourced Greetings —
#[EventType] - Step 15: What's Next
- PHP 8.3+ (8.4 recommended) and Composer 2.
- A terminal and a code editor.
curl(or any HTTP client) to exercise the endpoints as you build them.
See Installation for the full requirements list.
Install LaraFly and scaffold a new application with its bundled template:
composer global require fireflyframework/larafly
firefly new my-app
cd my-appfirefly/skeleton's post-create-project-cmd runs automatically and leaves you with a booting,
already-cached app: it copies .env.example to .env, touches database/database.sqlite, runs
php artisan key:generate, runs php artisan migrate (the sample resource stores into an orders table,
so POST /orders works on the first request rather than after a step you have to be told about), and runs
php artisan firefly:cache — the zero-reflection compile step you'll
revisit in Step 11. See
Installation for using the local template directly from a framework source checkout.
The scaffolded project looks like this:
my-app/
├── app/
│ ├── GreetingProperties.php # #[ConfigProperties('greeting')] DTO
│ ├── GreetingService.php # #[Service] bean
│ ├── Http/
│ │ ├── AddressPayload.php # a nested #[Valid] payload
│ │ ├── GreetingController.php # #[RestController] — JSON
│ │ ├── OrderController.php # #[RestController] — the full CRUD sample
│ │ ├── OrderLinePayload.php
│ │ ├── OrderRequest.php # the #[RequestBody] DTO, with constraints
│ │ └── WelcomeController.php # #[Controller] — the HTML welcome page at /
│ └── Orders/ # the sample's domain + repositories
├── bootstrap/
│ ├── app.php
│ ├── cache/firefly/ # compiled manifests — already populated
│ └── providers.php
├── config/
│ ├── app.php, cache.php, database.php, logging.php, queue.php, session.php
│ └── firefly.php # every firefly.* key, with its default and why
├── database/
│ ├── database.sqlite
│ └── migrations/ # the sample's orders tables
├── resources/views/welcome.blade.php
├── routes/
│ ├── console.php
│ └── web.php
├── tests/
├── artisan
└── composer.json
| File/Directory | Purpose |
|---|---|
app/ |
Your application code — controllers, services, domain classes, everything LaraFly scans. |
app/Orders/ + app/Http/Order*.php |
The sample vertical slice: a #[RestController] over #[Repository] beans, with a validated #[RequestBody]. Delete it when you no longer need it. |
config/firefly.php |
The full, commented reference of every firefly.* key the framework reads — starting with where your classes live. |
bootstrap/cache/firefly/ |
The compiled manifests firefly:cache writes — the zero-reflection boot path. |
database/database.sqlite |
The default database — no external services required to boot. |
Open config/firefly.php. It is long — it documents every firefly.* key the framework reads, each with
its default and the reason it has that default — but only two blocks matter before you write a line of code,
and they are the first two in the file:
'scan' => [
'paths' => [
'App\\' => app_path(),
],
],
// …
'cache' => [
'path' => base_path('bootstrap/cache/firefly'),
'component_manifest' => base_path('bootstrap/cache/firefly/component.php'),
'context_manifest' => base_path('bootstrap/cache/firefly/context.php'),
],scan.paths is a PSR-4 map (prefix → directory) — every #[Component]-family attribute (#[Service],
#[Repository], #[RestController], #[CommandHandler], …) under app_path() is discovered from here,
either by an in-process scan during development or, once compiled, from the cache manifests. This is the
one key an application must get right: leave it empty and the app boots with no routes, handlers, listeners,
scheduled tasks, constraints or method-security rules at all.
The skeleton ships two slices already wired end-to-end. app/Http/GreetingController.php, backed by
app/GreetingService.php and app/GreetingProperties.php, is the small one, and it is the one this
tutorial grows. app/Http/OrderController.php over app/Orders/ is the larger one — a full CRUD resource
with a validated request body — and you can read it when you want to see where this tutorial ends up.
Open app/Http/GreetingController.php — this is what the template already generated for you, in full:
<?php
declare(strict_types=1);
namespace App\Http;
use App\GreetingService;
use Firefly\Web\Attributes\GetMapping;
use Firefly\Web\Attributes\PathVariable;
use Firefly\Web\Attributes\RestController;
/**
* The sample Firefly slice: a #[RestController] whose routes are discovered by the RouteScanner and served
* from the compiled RouteManifest. GreetingService is autowired via constructor DI.
*
* `/` belongs to App\Http\WelcomeController, a #[Controller] that renders HTML — this one returns a value
* the ResponseFactory negotiates into JSON, which is the difference between the two stereotypes.
*/
#[RestController]
final class GreetingController
{
public function __construct(private readonly GreetingService $greetings) {}
/** @return array<string, string> */
#[GetMapping('/greetings/{name}', name: 'greetings.show')]
public function show(#[PathVariable] string $name): array
{
return ['message' => $this->greetings->greet($name)];
}
}Element by element:
#[RestController]— a stereotype that extendsFirefly\Container\Attributes\Component. Because the component scan discovers stereotypes withReflectionAttribute::IS_INSTANCEOF, a#[RestController]class is both auto-registered as a singleton DI bean and routed — no separate route registration.#[GetMapping('/greetings/{name}', name: 'greetings.show')]with#[PathVariable]— mapsGET /greetings/{name}toshow(), and binds the{name}route segment to the$nameparameter automatically.#[GetMapping]/#[PostMapping]/#[PutMapping]/#[PatchMapping]/#[DeleteMapping]each acceptpath,status(the default response status) and an optional Laravel routename.- A plain array return (
array<string, string>) is JSON-encoded by the framework's content negotiation — noResponseobject to build by hand.
The docblock names the one thing worth knowing before you go looking for GET /: it is not here.
/ is served by app/Http/WelcomeController.php, which carries #[Controller] — the HTML stereotype,
Spring's @Controller to this one's @RestController. The same route scan finds both because
#[Controller] extends #[RestController], so RouteScanner's IS_INSTANCEOF filter catches it with no
scanner change; the difference is that a #[Controller] action returns a view and this one returns a value
to negotiate.
A separate RouteScanner pass reads the routing metadata off the same class the component scan already
found — a #[RestController] never registers its own routes.
Open app/GreetingService.php — also already generated:
<?php
declare(strict_types=1);
namespace App;
use Firefly\Container\Attributes\Service;
/**
* A #[Service] stereotype: auto-registered as a singleton bean and resolved through the container, so its
* GreetingProperties dependency is autowired.
*/
#[Service]
final class GreetingService
{
public function __construct(private readonly GreetingProperties $properties) {}
public function greet(string $name): string
{
return sprintf('%s, %s!', $this->properties->salutation, $name);
}
}#[Service] is a #[Component] stereotype — semantically "business logic," registered as a singleton
DI bean exactly like #[RestController]. The constructor asks for a GreetingProperties instance; the
container inspects __construct()'s type hints and resolves it automatically — no factory, no XML, no
manual bind() call. GreetingController's own constructor works the same way: it asks for
GreetingService, and the container wires it in.
Open app/GreetingProperties.php:
<?php
declare(strict_types=1);
namespace App;
use Firefly\Config\Attributes\ConfigProperties;
/**
* Binds the `greeting.*` configuration subtree onto this readonly DTO and registers it as a container
* singleton, so it can be constructor-injected wherever GreetingProperties is requested.
*/
#[ConfigProperties('greeting')]
final readonly class GreetingProperties
{
public function __construct(public string $salutation = 'Hello') {}
}#[ConfigProperties('greeting')] binds the greeting config subtree (i.e. config('greeting')) onto
this readonly DTO and registers the bound instance as a container singleton, so it can be injected
wherever GreetingProperties is requested — exactly what GreetingService does above. There is no
config/greeting.php file in the skeleton, so config('greeting') resolves to an empty subtree; the
binder falls back to each constructor parameter's own default ('Hello') rather than failing. This is why
the endpoint already greets with "Hello" out of the box.
To override it, add a real Laravel config file — config/greeting.php:
<?php
declare(strict_types=1);
return [
'salutation' => env('GREETING_SALUTATION', 'Hello'),
];And in .env:
GREETING_SALUTATION="Howdy"
Nothing framework-specific here — #[ConfigProperties] binds from whatever config('greeting') resolves
to, and Laravel's own config/*.php + env() convention is how you populate it. No firefly:cache
re-run is needed for a plain config-value change (only for adding or changing annotated classes — see
Step 11).
Start the development server:
php artisan firefly:servefirefly:serve is a thin passthrough to artisan serve (or octane:start if laravel/octane is
installed), listening on 127.0.0.1:8000 by default. In another terminal:
curl http://127.0.0.1:8000/greetings/Ada
# {"message":"Hello, Ada!"}
curl -s http://127.0.0.1:8000/ | head -n 1
# <!DOCTYPE html> <- the welcome page, rendered by the #[Controller] stereotypeOpen http://127.0.0.1:8000/ in a browser too: the welcome page is not a static placeholder. It reads the
real BootPhase enum, the same BeansCatalog and ConditionEvaluationReport that /actuator/beans and
/actuator/conditions serve, and the same RouteManifest the dispatcher reads — so it shows your
application's actual routes, its bean count and whether this boot was compiled or scanned.
If you set GREETING_SALUTATION="Howdy" in Step 4, the
greeting request now returns "Howdy, Ada!" — no restart required, since config('greeting') is re-read
from the environment on each request.
So far greet() is stateless. Let's persist named greetings to the database.
First, generate a migration (a plain Laravel command — nothing Firefly-specific here):
php artisan make:migration create_greetings_tableEdit the generated database/migrations/..._create_greetings_table.php:
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('greetings', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('message');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('greetings');
}
};Run it — firefly:db is a thin passthrough to Laravel's own database commands (default action migrate):
php artisan firefly:dbINFO Running migrations.
2026_07_28_120500_create_greetings_table ... DONE
Now add the Eloquent model, app/Domain/Greeting.php:
<?php
declare(strict_types=1);
namespace App\Domain;
use Illuminate\Database\Eloquent\Model;
final class Greeting extends Model
{
/** @var list<string> */
protected $fillable = ['name', 'message'];
}And a repository, app/Infrastructure/GreetingRepository.php:
<?php
declare(strict_types=1);
namespace App\Infrastructure;
use App\Domain\Greeting;
use Firefly\Container\Attributes\Repository;
use Firefly\Data\Repository\EloquentRepository;
/**
* @extends EloquentRepository<Greeting>
* @method Greeting|null findFirstByName(string $name)
*/
#[Repository]
final class GreetingRepository extends EloquentRepository
{
protected string $model = Greeting::class;
}Element by element:
#[Repository]— another#[Component]stereotype (same family as#[Service]): a container bean, discovered the same way.EloquentRepositoryisfirefly/data's Eloquent-backed base implementing the CRUD/paging ports (save(),findById(),findAll(),count(), …). Settingprotected string $modelis the only thing a concrete repository needs to declare — the rest comes from the parent.findFirstByName(string $name)is never implemented — it doesn't need to be. It's a derived query:EloquentRepository::__call()parses the method name (DerivedQueryParser, pure string parsing, no reflection) intofind(prefix) +First(limit 1, returns a nullable single result rather than a list) +Name(mapped to thenamecolumn, implicitEqualsoperator), and drivesGreeting::query()->where('name', '=', $name)->first(). The@methoddocblock is only there so PHPStan sees a typed return for what is, at runtime, dynamic dispatch.
Wire the repository into the service, updating app/GreetingService.php:
<?php
declare(strict_types=1);
namespace App;
use App\Domain\Greeting;
use App\Infrastructure\GreetingRepository;
use Firefly\Container\Attributes\Service;
#[Service]
final class GreetingService
{
public function __construct(
private readonly GreetingProperties $properties,
private readonly GreetingRepository $repository,
) {}
public function greet(string $name): string
{
return sprintf('%s, %s!', $this->properties->salutation, $name);
}
public function record(string $name, string $message): Greeting
{
return $this->repository->save(new Greeting(['name' => $name, 'message' => $message]));
}
public function findRecord(string $name): ?Greeting
{
return $this->repository->findFirstByName($name);
}
}And expose it, adding a store() method to app/Http/GreetingController.php:
use App\Web\Dto\CreateGreetingRequest;
use Firefly\Web\Attributes\PostMapping;
use Firefly\Web\Attributes\RequestBody;
#[RestController]
final class GreetingController
{
// … the constructor and show() from Step 2 …
/** @return array<string, string> */
#[PostMapping('/greetings', status: 201)]
public function store(#[RequestBody] CreateGreetingRequest $body): array
{
$greeting = $this->greetings->record($body->name, $body->message);
return ['name' => $greeting->name, 'message' => $greeting->message];
}
}with a plain request DTO, app/Web/Dto/CreateGreetingRequest.php:
<?php
declare(strict_types=1);
namespace App\Web\Dto;
final class CreateGreetingRequest
{
public function __construct(
public readonly string $name,
public readonly string $message,
) {}
}#[RequestBody] decodes the JSON body and hydrates CreateGreetingRequest from it. Try it:
curl -X POST http://127.0.0.1:8000/greetings \
-H "Content-Type: application/json" \
-d '{"name": "Ada", "message": "Hello from the database!"}'
# {"name":"Ada","message":"Hello from the database!"}Nothing stops an empty body from being saved, though:
curl -X POST http://127.0.0.1:8000/greetings \
-H "Content-Type: application/json" \
-d '{"name": "", "message": ""}'
# {"name":"","message":""} <- saved! nothing validates this yet.We'll fix that next.
Add constraints to app/Web/Dto/CreateGreetingRequest.php:
<?php
declare(strict_types=1);
namespace App\Web\Dto;
use Firefly\Validation\Constraint\NotBlank;
use Firefly\Validation\Constraint\Size;
final class CreateGreetingRequest
{
public function __construct(
#[NotBlank]
#[Size(min: 2, max: 40)]
public readonly string $name,
#[NotBlank]
public readonly string $message,
) {}
}And gate the controller parameter with #[Valid], in app/Http/GreetingController.php:
use Firefly\Validation\Valid;
#[RestController]
final class GreetingController
{
// …
#[PostMapping('/greetings', status: 201)]
public function store(#[Valid] #[RequestBody] CreateGreetingRequest $body): array
{
$greeting = $this->greetings->record($body->name, $body->message);
return ['name' => $greeting->name, 'message' => $greeting->message];
}
}Element by element:
#[NotBlank](fromFirefly\Validation\Constraint) rejectsnull,'', and an all-whitespace string.#[Size(min: 2, max: 40)]bounds string length.#[Valid]on the#[RequestBody]parameter runs the raw decoded body throughFirefly\Validation\Constraint\BeanValidatoragainst the DTO's compiled constraint rules before the DTO is constructed. Only on success isCreateGreetingRequesthydrated — a failure throws the kernel'sValidationExceptionand the controller method body never runs.
Re-run the invalid request:
curl -X POST http://127.0.0.1:8000/greetings \
-H "Content-Type: application/json" \
-d '{"name": "", "message": ""}'{
"status": 422,
"title": "Unprocessable Entity",
"code": "VALIDATION_ERROR",
"category": "validation",
"severity": "warning",
"detail": "Validation failed",
"type": "about:blank",
"errors": [
{ "field": "name", "message": "must not be blank", "constraint": "NotBlank", "rejectedValue": "" },
{ "field": "message", "message": "must not be blank", "constraint": "NotBlank", "rejectedValue": "" }
]
}served as application/problem+json, with a 422 status — the same shape Step 8
covers in detail. The valid request from Step 6 still works exactly as before.
Add a lookup endpoint for a saved greeting, in app/Http/GreetingController.php:
use Firefly\Kernel\Exception\Business\ResourceNotFoundException;
#[RestController]
final class GreetingController
{
// …
/** @return array<string, string> */
#[GetMapping('/greetings/{name}/record')]
public function showRecord(#[PathVariable] string $name): array
{
$greeting = $this->greetings->findRecord($name);
if ($greeting === null) {
throw new ResourceNotFoundException("No saved greeting for '{$name}'");
}
return ['name' => $greeting->name, 'message' => $greeting->message];
}
}Every framework exception extends Firefly\Kernel\Exception\FireflyException, which fixes an HTTP status,
a stable machine-readable errorCode(), an ErrorCategory, and an ErrorSeverity.
Firefly\Kernel\Exception\Business\ResourceNotFoundException is a 404/RESOURCE_NOT_FOUND typed
subclass — you never write a try/catch in the controller. firefly/web's
Firefly\Web\Exception\ProblemDetailsRenderer turns any thrown FireflyException into an
application/problem+json response, globally, at the exception's own httpStatus().
Try it:
curl http://127.0.0.1:8000/greetings/Ada/record
# {"name":"Ada","message":"Hello from the database!"}
curl -i http://127.0.0.1:8000/greetings/Nowhere/recordHTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"status": 404,
"title": "Not Found",
"code": "RESOURCE_NOT_FOUND",
"category": "business",
"severity": "warning",
"detail": "No saved greeting for 'Nowhere'",
"type": "about:blank",
"instance": "/greetings/Nowhere/record"
}You did not write this renderer, a status-code table, or an error-response class — it is one consistent
mechanism across the whole framework. See Error Handling for the full
exception taxonomy (ConflictException, AuthenticationException, AuthorizationException, and more).
For a read, findRecord() from Step 6 is fine. But a mutation — renaming a saved greeting — is a good
candidate for firefly/cqrs: it gets transactional semantics and domain-event publication for free.
First, teach the Greeting model to raise a domain event when renamed. Update app/Domain/Greeting.php:
<?php
declare(strict_types=1);
namespace App\Domain;
use App\Domain\Event\GreetingRenamed;
use Firefly\Domain\HasDomainEvents;
use Firefly\Domain\RecordsDomainEvents;
use Illuminate\Database\Eloquent\Model;
final class Greeting extends Model implements RecordsDomainEvents
{
use HasDomainEvents;
/** @var list<string> */
protected $fillable = ['name', 'message'];
public function rename(string $newMessage): void
{
$old = $this->message;
$this->message = $newMessage;
$this->raiseEvent(new GreetingRenamed($this->name, $old, $newMessage));
}
}Firefly\Domain\HasDomainEvents is the trait form of Firefly\Domain\AggregateRoot's event-buffering
behaviour (raiseEvent()/pendingEvents()/pullEvents()/clearEvents()) for a class whose single
inheritance slot is already spent on Eloquent's own Model — implementing RecordsDomainEvents alongside
it gives you one object that is both a persisted row and an event-raising aggregate. (A pure, non-Eloquent
domain object would extend AggregateRoot directly instead — see Domain (DDD).)
Add the event, app/Domain/Event/GreetingRenamed.php:
<?php
declare(strict_types=1);
namespace App\Domain\Event;
use Firefly\Cqrs\Attributes\PublishDomainEvent;
use Firefly\Domain\DomainEvent;
#[PublishDomainEvent('greeting.events')]
final readonly class GreetingRenamed extends DomainEvent
{
public function __construct(
public string $name,
public string $oldMessage,
public string $newMessage,
) {
parent::__construct();
}
}#[PublishDomainEvent('greeting.events')] routes this event to the greeting.events integration-event
destination once it's bridged onto the eda bus after commit (see Step 10).
Now the write side — a command and its handler. app/Application/Command/RenameGreeting.php:
<?php
declare(strict_types=1);
namespace App\Application\Command;
final readonly class RenameGreeting
{
public function __construct(public string $name, public string $newMessage) {}
}app/Application/Command/RenameGreetingHandler.php:
<?php
declare(strict_types=1);
namespace App\Application\Command;
use App\Infrastructure\GreetingRepository;
use Firefly\Cqrs\Attributes\CommandHandler;
use Firefly\Data\Transaction\Attributes\Transactional;
use Firefly\Kernel\Exception\Business\ResourceNotFoundException;
/**
* Intentionally NOT final: the generated #[Transactional] proxy subclasses this handler.
*/
#[CommandHandler]
class RenameGreetingHandler
{
public function __construct(private readonly GreetingRepository $greetings) {}
#[Transactional]
public function handle(RenameGreeting $command): void
{
$greeting = $this->greetings->findFirstByName($command->name);
if ($greeting === null) {
throw new ResourceNotFoundException("No saved greeting for '{$command->name}'");
}
$greeting->rename($command->newMessage);
$this->greetings->save($greeting);
}
}And the read side — a query and its handler. app/Application/Query/GetGreeting.php:
<?php
declare(strict_types=1);
namespace App\Application\Query;
final readonly class GetGreeting
{
public function __construct(public string $name) {}
}app/Application/Query/GetGreetingHandler.php:
<?php
declare(strict_types=1);
namespace App\Application\Query;
use App\Domain\Greeting;
use App\Infrastructure\GreetingRepository;
use Firefly\Cqrs\Attributes\QueryHandler;
#[QueryHandler]
final class GetGreetingHandler
{
public function __construct(private readonly GreetingRepository $greetings) {}
public function handle(GetGreeting $query): ?Greeting
{
return $this->greetings->findFirstByName($query->name);
}
}Element by element:
#[CommandHandler]/#[QueryHandler]both specialise#[Component]— one attribute both registers the handler as a constructor-injected DI bean and tellsHandlerScannerhow to build the command/query → handler manifest. Each handler exposes exactly one publichandle()method; the handled message type is inferred from that method's sole parameter (pass an explicit class-string,#[CommandHandler(RenameGreeting::class)], only if you need to disambiguate).#[Transactional]onhandle()is load-bearing, not decorative: a generated proxy wraps the real call in a transaction, and after a successful commit it drainsGreeting's buffered domain events and publishes them — without it,GreetingRenamedwould never be raised onto the eda bus. See Transactions.RenameGreetingHandleris notfinal— the framework generatesRenameGreetingHandler__FireflyTransactionalProxy extends RenameGreetingHandler, which would fatal at class-load time if the parent werefinal.
Wire both buses into the controller and swap showRecord() over to QueryBus::ask(), updating
app/Http/GreetingController.php:
<?php
declare(strict_types=1);
namespace App\Http;
use App\Application\Command\RenameGreeting;
use App\Application\Query\GetGreeting;
use App\GreetingService;
use App\Web\Dto\CreateGreetingRequest;
use App\Web\Dto\RenameGreetingRequest;
use Firefly\Cqrs\Command\CommandBus;
use Firefly\Cqrs\Query\QueryBus;
use Firefly\Kernel\Exception\Business\ResourceNotFoundException;
use Firefly\Validation\Valid;
use Firefly\Web\Attributes\GetMapping;
use Firefly\Web\Attributes\PatchMapping;
use Firefly\Web\Attributes\PathVariable;
use Firefly\Web\Attributes\PostMapping;
use Firefly\Web\Attributes\RequestBody;
use Firefly\Web\Attributes\RestController;
#[RestController]
final class GreetingController
{
public function __construct(
private readonly GreetingService $greetings,
private readonly CommandBus $commands,
private readonly QueryBus $queries,
) {}
/** @return array<string, string> */
#[GetMapping('/greetings/{name}', name: 'greetings.show')]
public function show(#[PathVariable] string $name): array
{
return ['message' => $this->greetings->greet($name)];
}
/** @return array<string, string> */
#[PostMapping('/greetings', status: 201)]
public function store(#[Valid] #[RequestBody] CreateGreetingRequest $body): array
{
$greeting = $this->greetings->record($body->name, $body->message);
return ['name' => $greeting->name, 'message' => $greeting->message];
}
/** @return array<string, string> */
#[GetMapping('/greetings/{name}/record')]
public function showRecord(#[PathVariable] string $name): array
{
/** @var \App\Domain\Greeting|null $greeting */
$greeting = $this->queries->ask(new GetGreeting($name));
if ($greeting === null) {
throw new ResourceNotFoundException("No saved greeting for '{$name}'");
}
return ['name' => $greeting->name, 'message' => $greeting->message];
}
/** @return array<string, string> */
#[PatchMapping('/greetings/{name}/record')]
public function rename(#[PathVariable] string $name, #[Valid] #[RequestBody] RenameGreetingRequest $body): array
{
$this->commands->send(new RenameGreeting($name, $body->message));
/** @var \App\Domain\Greeting $greeting */
$greeting = $this->queries->ask(new GetGreeting($name));
return ['name' => $greeting->name, 'message' => $greeting->message];
}
}with one more small DTO, app/Web/Dto/RenameGreetingRequest.php:
<?php
declare(strict_types=1);
namespace App\Web\Dto;
use Firefly\Validation\Constraint\NotBlank;
final class RenameGreetingRequest
{
public function __construct(
#[NotBlank]
public readonly string $message,
) {}
}CommandBus::send(object $command): mixed is the write side; QueryBus::ask(object $query): mixed
is the read side (ask, not query, to avoid colliding with Eloquent's own query()). Both run a bounded
pipeline (correlate → validate → authorize → resolve + invoke handler → metrics) before reaching your
handler. Try it:
curl -X PATCH http://127.0.0.1:8000/greetings/Ada/record \
-H "Content-Type: application/json" \
-d '{"message": "Hello again from the database!"}'
# {"name":"Ada","message":"Hello again from the database!"}GreetingRenamed is a domain event, raised in-process by Greeting::rename(). Once
RenameGreetingHandler's #[Transactional] unit of work commits, firefly/cqrs's domain → integration-event
bridge re-publishes it onto the firefly/eda broker bus as an integration event — eventType becomes the
event's short class name ("GreetingRenamed"), and destination comes from #[PublishDomainEvent]
("greeting.events").
Add a listener, app/Listener/GreetingRenamedListener.php:
<?php
declare(strict_types=1);
namespace App\Listener;
use Firefly\Container\Attributes\Component;
use Firefly\Eda\Attributes\EventListener;
use Firefly\Eda\EventEnvelope;
use Illuminate\Support\Facades\Log;
#[Component]
final class GreetingRenamedListener
{
#[EventListener('GreetingRenamed')]
public function onGreetingRenamed(EventEnvelope $envelope): void
{
Log::info('greeting.renamed', $envelope->payload);
}
}Element by element:
#[Component]— a plain bean; the listener needs no HTTP or CQRS stereotype, just DI registration.#[EventListener('GreetingRenamed')]subscribes to the eda broker bus, not the in-process application-event bus (that's the unrelated#[AsEventListener]fromfirefly/context). The pattern is matched against the envelope'seventTypewithfnmatch()— a plain string like'GreetingRenamed'behaves as an exact match; you could also pass a glob ('Greeting*') or an array of several exact names. The pattern must name the event type, never the#[PublishDomainEvent]destination string.EventEnvelopecarrieseventType,destination,payload(the event's public fields —name,oldMessage,newMessage, plusDomainEvent's owneventId/occurredAt), andheaders.
With the skeleton's default in-memory eda provider, delivery is synchronous — the listener runs before the
PATCH request in Step 9 even returns. Re-run the PATCH from Step 9 and check the log:
tail -n 1 storage/logs/laravel.log[2026-07-28 12:10:03] local.INFO: greeting.renamed {"name":"Ada","oldMessage":"Hello from the database!","newMessage":"Hello again from the database!", ...}
See Event-Driven Architecture for the full broker/adapter model and CQRS for exactly how the domain → integration-event bridge triggers.
Every class you've added since Step 1 — the repository, the command/query handlers, the event listener — needs to be compiled into the app's manifests before it's picked up on a cached boot. Re-run:
php artisan firefly:cachefirefly:cache — wrote 16 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly
The manifest count is fixed at sixteen, because ManifestCacheWriter writes every artifact unconditionally
— an application with no #[Scheduled] method still gets an empty scheduled.php, which is what lets the
boot path treat "the file is missing" as "nobody has compiled yet" rather than as "there is nothing to
load". This single command drives every settled package's existing scanner → compiler pair over
config('firefly.scan.paths') and writes, into bootstrap/cache/firefly/: the DI/context/config-properties
manifests, the compiled route table, the #[ControllerAdvice] handler manifest, the validation constraint
manifest, the CQRS handler manifest, the event/message-listener manifests, the scheduled-task manifest, the
orchestration manifest (#[Saga]/#[Tcc]/#[Workflow]), the event-sourcing manifest (#[EventType]), the security-method manifest, the #[Transactional] manifest, the proxy plan (proxy-plan.php — which
beans get a proxy and which advice each of their methods runs) and the proxy classmap, and a generated
RenameGreetingHandler__FireflyTransactionalProxy class file (the "1 proxy" above). A proxy is emitted per
class the plan claims, which is every class any advice source names — so a #[Service] carrying only
#[PreAuthorize] is proxied exactly like a #[Transactional] one; in this application RenameGreetingHandler
happens to be the only one. A FireflyCacheServiceProvider binds these compiled manifests ahead of any bean
resolution, so every request after this runs against plain PHP arrays — no runtime reflection on the hot
path. Run it again any time you add or change an annotated class; php artisan firefly:clear deletes the
cache and falls back to the in-process scanner.
Now introspect the running app at the terminal, in-process — no HTTP round trip:
php artisan firefly:health{
"status": "UP"
}firefly:health resolves the same ActuatorRegistry a GET /actuator/health HTTP request would and
prints its response — the CLI reimplements no actuator logic of its own. Try the HTTP route too:
curl http://127.0.0.1:8000/actuator/health
# {"status":"UP"}
curl -i http://127.0.0.1:8000/actuator/env
# HTTP/1.1 404 Not Found <- sensitive endpoints are unexposed until you opt inSee CLI Reference for every firefly:* command and make:firefly-* generator (including
make:firefly-handler, make:firefly-listener, and make:firefly-repository, which scaffold the exact
stereotypes you just hand-wrote), and Actuator for the full endpoint list and
exposure/security model.
In Step 10 the listener ran inside the PATCH request, because the skeleton's event bus is in memory: fast, and
gone if the process dies between the commit and the listener. The database outbox closes that gap without a
line of your code changing. The event is written to a table in the same transaction as the renamed greeting,
and a worker delivers it afterwards — on SQLite, MySQL, MariaDB, PostgreSQL or SQL Server alike.
The provider is the first key of the eda block in config/firefly.php:
'eda' => [
'provider' => env('FIREFLY_EDA_PROVIDER', 'memory'),
// …
],Switch it with the environment variable and create the outbox table — the migration is registered as soon as the
provider is database:
echo 'FIREFLY_EDA_PROVIDER=database' >> .env
php artisan migrate # creates firefly_eda_outboxRestart php artisan firefly:serve, rename the greeting again, and look at the log:
curl -X PATCH http://127.0.0.1:8000/greetings/Ada/record \
-H "Content-Type: application/json" \
-d '{"message": "Hello from the outbox!"}'
# {"name":"Ada","message":"Hello from the outbox!"}
tail -n 1 storage/logs/laravel.log # still the line from Step 10: nothing has delivered the new event yetThe GreetingRenamed event is a PENDING row in firefly_eda_outbox, committed together with the new message.
Deliver it with the consumer — a long-running worker in production, one message here:
php artisan firefly:eda:consume --max-messages=1
tail -n 1 storage/logs/laravel.log[2026-07-28 12:20:41] local.INFO: greeting.renamed {"name":"Ada","oldMessage":"Hello again from the database!","newMessage":"Hello from the outbox!", ...}
What changed, and what did not:
- Your code did not.
GreetingRenamedListener,RenameGreetingHandlerand#[PublishDomainEvent]are exactly as Steps 9 and 10 left them. The outbox hooks in before the commit, through the same#[Transactional]boundary that published the event after it. - Atomicity did. If the handler throws after
rename(), the row rolls back with the greeting: no event for a write that never happened, and — because the row commits with the greeting — no lost event for one that did. - Delivery is a worker. The row is leased to one consumer before the listener runs, so you can run as many
php artisan firefly:eda:consumeworkers as you like. On SQLite theautoclaim strategy is an optimistic lease; on PostgreSQL, MySQL 8.0.1+ and MariaDB 10.6+ it isFOR UPDATE SKIP LOCKED; on SQL ServerREADPAST. A worker that crashes mid-delivery leaves its row to be delivered again when the lease (firefly.eda.database.lease-seconds, 300 by default) ends — so keep listeners idempotent. - PostgreSQL can do better.
FIREFLY_EDA_PROVIDER=postgresis the same outbox plus apg_notifyon write and aLISTENwake-up in the consumer, so delivery is immediate instead of polled.
See EDA Database Outbox for the claim strategies, the optional relay to RabbitMQ or
Kafka (php artisan firefly:outbox:relay), and the outbox health component, and the
Database Support Matrix for what each server does.
Everything so far has fit in one transaction. Selling a greeting card does not: it reserves a printed card from stock, charges the customer through a payment provider, and hands the card to a mailer — three systems, three commits, and no transaction that spans them. If the mailer fails, the charge has already happened. A saga is the answer: each step commits on its own, each step that changed something names a compensation that undoes it, and a failure runs the compensations of the completed steps, newest first.
The three systems get one stand-in, app/Cards/CardDesk.php — in a real application each would be an adapter
behind its own port; here one in-memory class keeps the step about the saga, and its $journal records every call:
<?php
declare(strict_types=1);
namespace App\Cards;
use Firefly\Container\Attributes\Component;
use RuntimeException;
/**
* A stand-in for the three systems a greeting-card order touches — the card stock, the payment provider and the
* mailer. A real application has one adapter per system; one in-memory class keeps this step about the saga.
* $journal records every call, in order.
*/
#[Component]
final class CardDesk
{
/** @var list<string> */
public array $journal = [];
private int $cards = 0;
private int $payments = 0;
public function reserve(): string
{
$card = 'card-'.++$this->cards;
$this->journal[] = "reserve {$card}";
return $card;
}
public function release(string $card): void
{
$this->journal[] = "release {$card}";
}
public function charge(int $amountMinor): string
{
$payment = 'payment-'.++$this->payments;
$this->journal[] = "charge {$payment} {$amountMinor}";
return $payment;
}
public function refund(string $payment): void
{
$this->journal[] = "refund {$payment}";
}
public function send(string $card, string $to): string
{
if ($to === 'Nobody') {
throw new RuntimeException("The mailer has no address for {$to}.");
}
$this->journal[] = "send {$card} to {$to}";
return "sent {$card} to {$to}";
}
}Now the saga, app/Cards/GreetingCardSaga.php:
<?php
declare(strict_types=1);
namespace App\Cards;
use Firefly\Container\Attributes\Service;
use Firefly\Orchestration\Core\Argument\FromStep;
use Firefly\Orchestration\Core\Argument\Input;
use Firefly\Orchestration\Saga\Attributes\Saga;
use Firefly\Orchestration\Saga\Attributes\SagaStep;
#[Service]
#[Saga(name: 'greeting-card')]
final class GreetingCardSaga
{
public const int PRICE_MINOR = 300;
public function __construct(private readonly CardDesk $desk) {}
#[SagaStep(id: 'reserve', compensate: 'release')]
public function reserve(): string
{
return $this->desk->reserve();
}
public function release(string $card): void
{
$this->desk->release($card);
}
#[SagaStep(id: 'charge', dependsOn: ['reserve'], compensate: 'refund')]
public function charge(): string
{
return $this->desk->charge(self::PRICE_MINOR);
}
public function refund(string $payment): void
{
$this->desk->refund($payment);
}
#[SagaStep(id: 'send', dependsOn: ['charge'])]
public function send(#[FromStep('reserve')] string $card, #[Input('to')] string $to): string
{
return $this->desk->send($card, $to);
}
}Element by element:
#[Saga(name: 'greeting-card')]on a#[Service]bean declares the saga;firefly:cachecompiles it into the orchestration manifest, and the topology is validated at boot.#[SagaStep(id: 'reserve', compensate: 'release')]— a forward step and the method that undoes it. The compensation receives the step's result:release()gets the card idreserve()returned.dependsOn: ['reserve']orders the steps into layers:chargeruns only afterreservesucceeded,sendonly aftercharge.#[FromStep('reserve')]handssend()the card the first step reserved;#[Input('to')]reads a key of the saga's input.sendhas no compensation: it is the last step, so nothing after it can fail and strand it.
Expose it, app/Http/GreetingCardController.php:
<?php
declare(strict_types=1);
namespace App\Http;
use Firefly\Kernel\Exception\Business\ConflictException;
use Firefly\Orchestration\Saga\Engine\SagaEngine;
use Firefly\Web\Attributes\PathVariable;
use Firefly\Web\Attributes\PostMapping;
use Firefly\Web\Attributes\RestController;
#[RestController]
final class GreetingCardController
{
public function __construct(private readonly SagaEngine $sagas) {}
/** @return array{order: string, status: string} */
#[PostMapping('/cards/{to}', status: 201)]
public function send(#[PathVariable] string $to): array
{
$result = $this->sagas->execute('greeting-card', ['to' => $to]);
if (! $result->success) {
throw new ConflictException("The card for {$to} was not sent; every completed step was undone.", 'CARD_NOT_SENT', $result->error);
}
return ['order' => $result->correlationId, 'status' => $result->status->value];
}
}SagaEngine::execute() runs the saga inside the request and returns a SagaResult; a failed saga becomes a
ConflictException, which the web layer renders as RFC-7807 problem+json exactly like Step 8's errors. Recompile
and try both cases:
php artisan firefly:cache
curl -X POST http://127.0.0.1:8000/cards/Ada
# {"order":"…","status":"COMPLETED"}
curl -i -X POST http://127.0.0.1:8000/cards/Nobody
# HTTP/1.1 409 Conflict <- problem+json with code CARD_NOT_SENTThe second order reserved a card and charged the customer before the mailer refused. The journal shows what
happened next, and the repository's own test (tests/TutorialGreetingCardSagaTest.php) asserts exactly this
order:
reserve card-1
charge payment-1 300
refund payment-1
release card-1
The charge was refunded before the card was released — newest first, the strict-sequential policy that
firefly.orchestration.saga.compensation-policy defaults to. By default the saga's checkpoints and its dead
letters live in memory, which is right for a saga that finishes inside one request. Set
FIREFLY_ORCHESTRATION_PERSISTENCE=database and FIREFLY_ORCHESTRATION_DLQ_STORE=database (after
php artisan vendor:publish --tag=firefly-orchestration-migrations and php artisan migrate) and every step
boundary is written to firefly_orchestration_executions, a failed order lands in
firefly_orchestration_dead_letters, and php artisan firefly:orchestration:list --name=greeting-card and
php artisan firefly:orchestration:dlq --name=greeting-card show them. See Orchestration
for the five compensation policies, TCC, durable workflows that wait for signals and timers, recovery, and the
operator commands.
Step 6 stored each greeting as a row, and Step 9's rename overwrote it: the table knows the current message and nothing about the ones before it. Event sourcing keeps that other half. In this step every greeting you send to a name is recorded as an event in an event store, a small aggregate is rebuilt from those events on every request, and a projection keeps a read model — how many times each name was greeted, and the last message — up to date from a checkpoint.
The event store's provider sits in the eventsourcing block of config/firefly.php:
'eventsourcing' => [
// …
'store' => [
'provider' => env('FIREFLY_EVENTSOURCING_STORE', 'memory'),
'connection' => env('FIREFLY_EVENTSOURCING_DB_CONNECTION'),
'batch-size' => 500,
],
// …
],memory forgets everything when the process ends; move it to the database:
echo 'FIREFLY_EVENTSOURCING_STORE=database' >> .envFirst the event, app/Greetings/GreetingSaid.php. #[EventType] gives it the stable name the store keeps:
<?php
declare(strict_types=1);
namespace App\Greetings;
use DateTimeImmutable;
use Firefly\Domain\DomainEvent;
use Firefly\EventSourcing\Event\Attributes\EventType;
#[EventType(name: 'greeting.said')]
final readonly class GreetingSaid extends DomainEvent
{
public function __construct(
public string $name,
public string $message,
?string $eventId = null,
?DateTimeImmutable $occurredAt = null,
) {
parent::__construct($eventId, $occurredAt);
}
}Then the aggregate, app/Greetings/GreetingJournal.php — one journal per name, its id the name itself:
<?php
declare(strict_types=1);
namespace App\Greetings;
use Firefly\EventSourcing\Aggregate\EventSourcedAggregateRoot;
final class GreetingJournal extends EventSourcedAggregateRoot
{
private int $times = 0;
private string $lastMessage = '';
public static function aggregateType(): string
{
return 'greeting.journal';
}
public static function begin(string $name, string $message): self
{
$journal = new self($name);
$journal->recordThat(new GreetingSaid($name, $message));
return $journal;
}
public function say(string $message): void
{
$this->recordThat(new GreetingSaid($this->id(), $message));
}
public function times(): int
{
return $this->times;
}
public function lastMessage(): string
{
return $this->lastMessage;
}
protected function applyGreetingSaid(GreetingSaid $event): void
{
$this->times++;
$this->lastMessage = $event->message;
}
}Its repository, app/Greetings/GreetingJournals.php, is one constructor:
<?php
declare(strict_types=1);
namespace App\Greetings;
use Firefly\Container\Attributes\Component;
use Firefly\EventSourcing\Aggregate\EventSourcedRepository;
use Firefly\EventSourcing\Aggregate\EventSourcingSupport;
/** @extends EventSourcedRepository<GreetingJournal> */
#[Component]
final class GreetingJournals extends EventSourcedRepository
{
public function __construct(EventSourcingSupport $support)
{
parent::__construct($support, GreetingJournal::class);
}
}The read model is an ordinary table. Create database/migrations/2026_10_13_000000_create_greeting_counts_table.php:
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('greeting_counts', function (Blueprint $table): void {
$table->string('name')->primary();
$table->unsignedInteger('times');
$table->string('last_message');
});
}
public function down(): void
{
Schema::dropIfExists('greeting_counts');
}
};and the projection that fills it, app/Greetings/GreetingCounts.php:
<?php
declare(strict_types=1);
namespace App\Greetings;
use Firefly\Container\Attributes\Component;
use Firefly\EventSourcing\Event\StoredEvent;
use Firefly\EventSourcing\Projection\Projection;
use Illuminate\Support\Facades\DB;
#[Component]
final class GreetingCounts implements Projection
{
public const string TABLE = 'greeting_counts';
public function name(): string
{
return 'greeting.counts';
}
public function handles(): array
{
return ['greeting.said'];
}
public function handle(StoredEvent $event): void
{
$message = $event->payload['message'] ?? '';
$message = is_string($message) ? $message : '';
if (DB::table(self::TABLE)->where('name', $event->aggregateId)->exists()) {
DB::table(self::TABLE)->where('name', $event->aggregateId)->increment('times', 1, ['last_message' => $message]);
return;
}
DB::table(self::TABLE)->insert(['name' => $event->aggregateId, 'times' => 1, 'last_message' => $message]);
}
public function reset(): void
{
DB::table(self::TABLE)->delete();
}
/** @return array{name: string, times: int, lastMessage: string}|null */
public static function find(string $name): ?array
{
$row = DB::table(self::TABLE)->where('name', $name)->first();
if ($row === null) {
return null;
}
$values = (array) $row;
$times = $values['times'] ?? 0;
$message = $values['last_message'] ?? '';
return ['name' => $name, 'times' => is_numeric($times) ? (int) $times : 0, 'lastMessage' => is_string($message) ? $message : ''];
}
}Finally the endpoints, app/Http/GreetingJournalController.php — the POST writes through the aggregate, the GET
reads the projection:
<?php
declare(strict_types=1);
namespace App\Http;
use App\Greetings\GreetingCounts;
use App\Greetings\GreetingJournal;
use App\Greetings\GreetingJournals;
use Firefly\Kernel\Exception\Business\ResourceNotFoundException;
use Firefly\Web\Attributes\GetMapping;
use Firefly\Web\Attributes\PathVariable;
use Firefly\Web\Attributes\PostMapping;
use Firefly\Web\Attributes\RestController;
#[RestController]
final class GreetingJournalController
{
public function __construct(private readonly GreetingJournals $journals) {}
/** @return array{name: string, times: int} */
#[PostMapping('/journal/{name}', status: 201)]
public function say(#[PathVariable] string $name): array
{
$message = "Hello, {$name}!";
$journal = $this->journals->find($name);
if ($journal === null) {
$journal = GreetingJournal::begin($name, $message);
} else {
$journal->say($message);
}
$this->journals->save($journal);
return ['name' => $name, 'times' => $journal->times()];
}
/** @return array{name: string, times: int, lastMessage: string} */
#[GetMapping('/journal/{name}')]
public function show(#[PathVariable] string $name): array
{
return GreetingCounts::find($name)
?? throw new ResourceNotFoundException("No greeting to {$name} has been projected yet.");
}
}Element by element:
#[EventType(name: 'greeting.said')]names the event for the store;firefly:cachecompiles it, and an event class without one is refused at append, so nothing is stored that cannot be read back.GreetingJournalholds no row.recordThat()applies the event at once and buffers it;applyGreetingSaid()is the only code that changestimesandlastMessage, so replaying the events rebuilds the same journal.GreetingJournals::find()replays a name's events (nullwhen there are none);save()appends the new ones under the version it loaded — two requests that greet Ada from the same version cannot both win: the second gets a 409ConcurrencyException.GreetingCountsis a#[Component], so the projection runner finds it. It reads the global stream from its checkpoint, and its rows and the checkpoint commit in one transaction on the same connection.
Recompile, migrate, and greet Ada twice:
php artisan firefly:cache
php artisan migrate # the event store's tables and greeting_counts
curl -X POST http://127.0.0.1:8000/journal/Ada
# {"name":"Ada","times":1}
curl -X POST http://127.0.0.1:8000/journal/Ada
# {"name":"Ada","times":2}
curl -i http://127.0.0.1:8000/journal/Ada
# HTTP/1.1 404 Not Found <- problem+json: nothing has projected the greetings yetThe two events are in firefly_eventsourcing_events; the read model has not heard of them. Catch it up:
php artisan firefly:eventsourcing:projections:run
# INFO greeting.counts: caught-up, 2 event(s) handled, at position 2.
curl http://127.0.0.1:8000/journal/Ada
# {"name":"Ada","times":2,"lastMessage":"Hello, Ada!"}The repository's own test (tests/TutorialGreetingJournalTest.php) makes exactly these requests and runs exactly
this command. In production keep php artisan firefly:eventsourcing:projections:run --live running under a process
supervisor; php artisan firefly:eventsourcing:projections shows every projection's checkpoint and lag, and
php artisan firefly:eventsourcing:projections:rebuild greeting.counts throws the read model away and replays
every event into it — the test deletes greeting_counts and gets it back that way. See
Event Sourcing for snapshots, upcasters, publishing appended events through the outbox,
and the event-sourced orchestration store, and Chapter 9B of the book for all of them on the Lumen sample.
You've built one small feature slice — #[RestController] → #[Service] → #[ConfigProperties] →
#[Repository] → #[Valid] → RFC-7807 errors → #[CommandHandler]/#[QueryHandler] →
#[EventListener] → a database outbox → a #[Saga] → an #[EventType] journal and its projection →
a cached, zero-reflection boot — using nothing but the framework's own real, shipped attributes. From here:
- Dependency Injection — the full
#[Component]family, scopes, conditional beans, and how the compiled manifest works. - Configuration — profiles,
#[Value], and nested#[ConfigProperties]DTOs. - Validation — the full constraint catalogue (financial-domain rules included).
- Web Layer — parameter binding (
#[QueryParam],#[RequestHeader],#[UploadedFile]), content negotiation, and the full#[Valid]cascade model. - Data & Repositories — the full derived-query grammar,
#[Query],Specifications, and pagination. - Relational Data and Transactions — everything
EloquentRepositoryand#[Transactional]do beyond this tutorial (propagation modes, isolation, rollback rules). - Domain (DDD) —
Entity,ValueObject,AggregateRoot, and the full after-commit event model. - Error Handling — the complete exception taxonomy and
#[ExceptionHandler]/#[ControllerAdvice].
- CQRS (Command/Query) — the bounded pipeline, the domain → integration-event bridge in full, and authorization/caching seams.
- Event-Driven Architecture — retry/DLQ policy, the queue adapter, and the two-event-surfaces
distinction (
#[AsEventListener]vs.#[EventListener]). - EDA Brokers and Messaging — real broker adapters and the
lower-level raw-bytes
MessageBrokerPort. - EDA Database Outbox — the portable outbox from Step 12: claim strategies, the relay, and the database support matrix.
- Orchestration — sagas, TCC and durable workflows: everything Step 13 left out.
- Webhooks and Callbacks — signed webhooks in, signed callbacks out, both on the event bus.
- Event Sourcing — the event store from Step 14, plus snapshots, upcasters, publishing through the outbox, and sagas stored as event streams.
- Security — session-persisted authentication, form login, HTTP Basic, JWT,
deny-by-default
HttpSecurityURL rules, and#[PreAuthorize]on any stereotyped bean. - OAuth2 Client and OAuth2 Authorization Server — signing users in with Google, GitHub, Okta, Keycloak or Entra, and issuing your own tokens.
- Actuator and Observability — the full endpoint set and the Prometheus/Micrometer-style metrics core.
- Tracing and Logging — a W3C
traceparentcarried across HTTP, the CQRS buses and the event bus, and the same ids on every structured log record. - Resilience, Scheduling and
Scheduling Database Locks —
Retry/CircuitBreaker/Bulkhead,#[Scheduled], and the lock that keeps a task on one node. - Testing — the
firefly/testingboot harness and recording doubles for every port used in this tutorial, and Integration Testing for the same tests on real servers.
The Lumen sample is a
runnable digital-wallet & ledger vertical slice exercising the exact same primitives you just used —
#[Transactional], CQRS, domain events over EDA, method security, and RFC-7807 error rendering — at the
scale of a full aggregate with multiple commands, queries, and a read-model projector.
Apache-2.0 © Firefly Software Solutions Inc.