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, 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 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/
│ │ ├── 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",
"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'",
"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 14 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly
The manifest count is fixed at fourteen, 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
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.
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 — 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 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.