This is a hand-built, step-by-step walkthrough of building a real LaraFly feature from an empty
composer create-project 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, and a domain event listener. 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,
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: 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.
Scaffold a new LaraFly application with the firefly/skeleton Composer template:
composer create-project firefly/skeleton 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, and runs php artisan firefly:cache — the zero-reflection compile step you'll
revisit in Step 11. See
Installation for the equivalent firefly new my-app global-installer shortcut.
The scaffolded project looks like this:
my-app/
├── app/
│ ├── GreetingProperties.php # #[ConfigProperties('greeting')] DTO
│ ├── GreetingService.php # #[Service] bean
│ └── Http/
│ └── GreetingController.php # #[RestController]
├── bootstrap/
│ ├── app.php
│ ├── cache/firefly/ # compiled manifests — already populated
│ └── providers.php
├── config/
│ ├── app.php
│ ├── database.php
│ └── firefly.php # scan paths + cache paths
├── database/
│ └── database.sqlite
├── routes/
│ ├── console.php
│ └── web.php
├── artisan
└── composer.json
| File/Directory | Purpose |
|---|---|
app/ |
Your application code — controllers, services, domain classes, everything LaraFly scans. |
config/firefly.php |
Tells LaraFly's component scan and firefly:cache 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:
return [
'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. The
skeleton ships one slice already wired end-to-end: app/Http/GreetingController.php, backed by
app/GreetingService.php and app/GreetingProperties.php. The rest of this tutorial grows that slice.
Open app/Http/GreetingController.php — this is what firefly new already generated for you:
<?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;
#[RestController]
final class GreetingController
{
public function __construct(private readonly GreetingService $greetings) {}
/** @return array<string, string> */
#[GetMapping('/')]
public function index(): array
{
return ['message' => $this->greetings->greet('World')];
}
/** @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('/')]— mapsGET /toindex().#[GetMapping]/#[PostMapping]/#[PutMapping]/#[PatchMapping]/#[DeleteMapping]each acceptpath,status(default response status), and an optional Laravel routename.#[GetMapping('/greetings/{name}', name: 'greetings.show')]with#[PathVariable]— the{name}route segment is bound to the$nameparameter automatically.- A plain array return (
array<string, string>) is JSON-encoded by the framework's content negotiation — noResponseobject to build by hand.
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;
#[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;
#[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/
# {"message":"Hello, World!"}
curl http://127.0.0.1:8000/greetings/Ada
# {"message":"Hello, Ada!"}If you set GREETING_SALUTATION="Howdy" in Step 4, the
same two requests now return "Howdy, World!" and "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;
// ...inside GreetingController...
/** @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;
#[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",
"errors": [
{ "field": "name", "message": "The name field is required." },
{ "field": "message", "message": "The message field is required." }
]
}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;
/** @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'",
"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('/')]
public function index(): array
{
return ['message' => $this->greetings->greet('World')];
}
/** @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 12 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly
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 validation constraint manifest, the CQRS handler manifest, the
event/message-listener manifests, the scheduled-task manifest, the security-method manifest, the
#[Transactional] manifest, and a generated RenameGreetingHandler__FireflyTransactionalProxy class
file (the "1 proxy" above — one per #[Transactional] target in your app). 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.
You've built one small feature slice — #[RestController] → #[Service] → #[ConfigProperties] →
#[Repository] → #[Valid] → RFC-7807 errors → #[CommandHandler]/#[QueryHandler] →
#[EventListener] → 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.
- Security — authentication, deny-by-default
HttpSecurity, and#[PreAuthorize]. - Actuator and Observability — the full endpoint set and the Prometheus/Micrometer-style metrics core.
- Resilience and Scheduling —
Retry/CircuitBreaker/Bulkheadand#[Scheduled]. - Testing — the
firefly/testingboot harness and recording doubles for every port used in this tutorial.
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.