Este es un recorrido paso a paso, construido a mano, de cómo levantar una funcionalidad LaraFly real desde
una aplicación creada con firefly new hasta una porción completa gobernada por atributos — un controlador REST,
un servicio, configuración tipada, un modelo de dominio respaldado por un repositorio, validación de
peticiones, manejo de errores RFC-7807, un par de comando/consulta CQRS, un listener de evento de dominio,
un outbox agnóstico de la base de datos, una saga que se compensa y un diario de greetings con event sourcing. Cada bloque de código de abajo es fiel
al framework tal como se distribuye en este repositorio — los atributos, nombres de clase y firmas de
método se toman literalmente de las fuentes de los paquetes firefly/* y del sample de wallet ejecutable
samples/lumen; no se inventan para el tutorial.
Al terminar habrás hecho crecer la porción de ejemplo Greeting del starter firefly/skeleton hasta
convertirla en una pequeña funcionalidad de "greetings" con una tabla greetings persistida, un
#[EventListener] que reacciona a un evento de dominio entregado a través de un outbox de base de datos, una
saga de tarjetas de felicitación que se deshace sola, un diario de greetings con event sourcing proyectado en un
modelo de lectura y un arranque cacheado sin reflexión — la misma arquitectura que usa el
sample de Lumen a mayor escala.
- Paso 1: crea la aplicación
- Paso 2: tu primer endpoint —
#[RestController] - Paso 3: autoinyección de un servicio —
#[Service] - Paso 4: configuración tipada —
#[ConfigProperties] - Paso 5: ejecútala y llama a la API
- Paso 6: persistiendo greetings —
#[Repository]y consultas derivadas - Paso 7: validando el cuerpo de la petición —
#[Valid] - Paso 8: manejo de errores RFC-7807
- Paso 9: comandos y consultas — CQRS
- Paso 10: reaccionando a un evento de dominio —
#[EventListener] - Paso 11: la caché sin reflexión y la introspección de salud
- Paso 12: un outbox agnóstico de la base de datos y un consumidor
- Paso 13: una saga con compensación —
#[Saga] - Paso 14: greetings con event sourcing —
#[EventType] - Paso 15: y ahora qué
- PHP 8.3+ (se recomienda 8.4) y Composer 2.
- Un terminal y un editor de código.
curl(o cualquier cliente HTTP) para probar los endpoints a medida que los construyes.
Consulta Instalación para la lista completa de requisitos.
Instala LaraFly y genera una nueva aplicación con la plantilla incluida:
composer global require fireflyframework/larafly
firefly new my-app
cd my-appEl post-create-project-cmd de firefly/skeleton se ejecuta automáticamente y te deja con una app que ya
arranca y que ya está cacheada: copia .env.example a .env, crea database/database.sqlite, ejecuta
php artisan key:generate, ejecuta php artisan migrate (el recurso de ejemplo persiste en una tabla
orders, así que POST /orders funciona en la primera petición y no tras un paso que alguien tenga que
contarte) y ejecuta php artisan firefly:cache — el paso de compilación sin reflexión que retomarás en el
Paso 11. Consulta
Instalación para usar la plantilla local directamente desde un checkout del framework.
El proyecto generado tiene este aspecto:
my-app/
├── app/
│ ├── GreetingProperties.php # DTO #[ConfigProperties('greeting')]
│ ├── GreetingService.php # bean #[Service]
│ ├── Http/
│ │ ├── AddressPayload.php # un payload #[Valid] anidado
│ │ ├── GreetingController.php # #[RestController] — JSON
│ │ ├── OrderController.php # #[RestController] — el ejemplo CRUD completo
│ │ ├── OrderLinePayload.php
│ │ ├── OrderRequest.php # el DTO #[RequestBody], con restricciones
│ │ └── WelcomeController.php # #[Controller] — la página HTML de bienvenida en /
│ └── Orders/ # el dominio y los repositorios del ejemplo
├── bootstrap/
│ ├── app.php
│ ├── cache/firefly/ # manifiestos compilados — ya poblados
│ └── providers.php
├── config/
│ ├── app.php, cache.php, database.php, logging.php, queue.php, session.php
│ └── firefly.php # cada clave firefly.*, con su valor por defecto y su porqué
├── database/
│ ├── database.sqlite
│ └── migrations/ # las tablas orders del ejemplo
├── resources/views/welcome.blade.php
├── routes/
│ ├── console.php
│ └── web.php
├── tests/
├── artisan
└── composer.json| Fichero/Directorio | Propósito |
|---|---|
app/ |
El código de tu aplicación — controladores, servicios, clases de dominio, todo lo que LaraFly escanea. |
app/Orders/ + app/Http/Order*.php |
La porción vertical de ejemplo: un #[RestController] sobre beans #[Repository], con un #[RequestBody] validado. Bórrala cuando ya no la necesites. |
config/firefly.php |
La referencia completa y comentada de cada clave firefly.* que lee el framework — empezando por dónde viven tus clases. |
bootstrap/cache/firefly/ |
Los manifiestos compilados que escribe firefly:cache — la ruta de arranque sin reflexión. |
database/database.sqlite |
La base de datos por defecto — no se necesita ningún servicio externo para arrancar. |
Abre config/firefly.php. Es largo — documenta cada clave firefly.* que lee el framework, con su valor
por defecto y la razón de ese valor —, pero antes de escribir una sola línea de código solo importan dos
bloques, y son los dos primeros del fichero:
'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 es un mapa PSR-4 (prefijo → directorio) — todo atributo de la familia #[Component]
(#[Service], #[Repository], #[RestController], #[CommandHandler], …) bajo app_path() se descubre
desde aquí, ya sea mediante un escaneo en proceso durante el desarrollo o, una vez compilado, desde los
manifiestos de cache. Es la única clave que una aplicación tiene que acertar: déjala vacía y la app
arranca sin rutas, sin handlers, sin listeners, sin tareas programadas, sin restricciones y sin reglas de
seguridad de método.
El skeleton trae ya dos porciones cableadas de extremo a extremo. app/Http/GreetingController.php,
respaldado por app/GreetingService.php y app/GreetingProperties.php, es la pequeña, y es la que hace
crecer este tutorial. app/Http/OrderController.php sobre app/Orders/ es la grande — un recurso CRUD
completo con un cuerpo de petición validado — y puedes leerla cuando quieras ver adónde llega este
tutorial.
Abre app/Http/GreetingController.php — esto es lo que la plantilla ya generó por ti, íntegro:
<?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)];
}
}Elemento por elemento:
#[RestController]— un estereotipo que extiendeFirefly\Container\Attributes\Component. Como el component scan descubre estereotipos conReflectionAttribute::IS_INSTANCEOF, una clase#[RestController]queda a la vez auto-registrada como bean singleton de DI y enrutada — sin registro de rutas por separado.#[GetMapping('/greetings/{name}', name: 'greetings.show')]junto con#[PathVariable]— mapeaGET /greetings/{name}ashow()y vincula automáticamente el segmento de ruta{name}al parámetro$name.#[GetMapping]/#[PostMapping]/#[PutMapping]/#[PatchMapping]/#[DeleteMapping]aceptan cada unopath,status(el status de respuesta por defecto) y unnameopcional de ruta Laravel.- Un retorno de array plano (
array<string, string>) se codifica a JSON por la negociación de contenido del framework — no hay que construir un objetoResponsea mano.
El docblock nombra lo único que conviene saber antes de ponerse a buscar GET /: aquí no está. De / se
encarga app/Http/WelcomeController.php, que lleva #[Controller] — el estereotipo HTML, el
@Controller de Spring frente al @RestController de este. El mismo escaneo de rutas encuentra los dos
porque #[Controller] extiende #[RestController], así que el filtro IS_INSTANCEOF de RouteScanner lo
captura sin cambiar el escáner; la diferencia es que una acción #[Controller] devuelve una vista y esta
devuelve un valor que negociar.
Un RouteScanner aparte lee los metadatos de enrutamiento de la misma clase que ya encontró el component
scan — un #[RestController] nunca registra sus propias rutas.
Abre app/GreetingService.php — también generado ya:
<?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] es un estereotipo #[Component] — semánticamente "lógica de negocio", registrado como
bean singleton de DI exactamente igual que #[RestController]. El constructor pide una instancia de
GreetingProperties; el contenedor inspecciona los type hints de __construct() y la resuelve
automáticamente — sin factory, sin XML, sin llamada manual a bind(). El propio constructor de
GreetingController funciona igual: pide GreetingService, y el contenedor lo cablea.
Abre 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')] enlaza el subárbol de configuración greeting (es decir,
config('greeting')) sobre este DTO de solo lectura y registra la instancia ya enlazada como singleton
del contenedor, de modo que puede inyectarse allí donde se solicite GreetingProperties — exactamente lo
que hace GreetingService arriba. No existe ningún fichero config/greeting.php en el skeleton, así que
config('greeting') resuelve a un subárbol vacío; el binder recurre entonces al valor por defecto propio
de cada parámetro del constructor ('Hello') en lugar de fallar. Por eso el endpoint ya saluda con "Hello"
tal cual, sin configuración adicional.
Para sobrescribirlo, añade un fichero de configuración Laravel real — config/greeting.php:
<?php
declare(strict_types=1);
return [
'salutation' => env('GREETING_SALUTATION', 'Hello'),
];Y en .env:
GREETING_SALUTATION="Howdy"Aquí no hay nada específico del framework — #[ConfigProperties] enlaza a partir de lo que resuelva
config('greeting'), y la propia convención de Laravel de config/*.php + env() es la que usas para
poblarlo. No hace falta volver a ejecutar firefly:cache por un simple cambio de valor de configuración
(solo al añadir o modificar clases anotadas — ver el Paso 11).
Arranca el servidor de desarrollo:
php artisan firefly:servefirefly:serve es un passthrough delgado a artisan serve (o a octane:start si laravel/octane está
instalado), escuchando por defecto en 127.0.0.1:8000. En otro 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> <- la página de bienvenida, renderizada por el estereotipo #[Controller]Abre también http://127.0.0.1:8000/ en un navegador: la página de bienvenida no es un marcador de
posición estático. Lee el enum BootPhase real, el mismo BeansCatalog y el mismo
ConditionEvaluationReport que sirven /actuator/beans y /actuator/conditions, y el mismo
RouteManifest que lee el dispatcher — así que muestra las rutas reales de tu aplicación, su número de
beans y si este arranque fue compilado o escaneado.
Si configuraste GREETING_SALUTATION="Howdy" en el Paso 4,
la petición de saludo ahora devuelve "Howdy, Ada!" — sin necesidad de reiniciar, ya que
config('greeting') se relee del entorno en cada petición.
Hasta ahora greet() no tiene estado. Vamos a persistir greetings con nombre en la base de datos.
Primero, genera una migración (un comando de Laravel corriente — nada específico de Firefly aquí):
php artisan make:migration create_greetings_tableEdita el database/migrations/..._create_greetings_table.php generado:
<?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');
}
};Ejecútala — firefly:db es un passthrough delgado a los propios comandos de base de datos de Laravel
(acción por defecto migrate):
php artisan firefly:dbINFO Running migrations.
2026_07_28_120500_create_greetings_table ... DONEAhora añade el modelo Eloquent, 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'];
}Y un repositorio, 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;
}Elemento por elemento:
#[Repository]— otro estereotipo#[Component](de la misma familia que#[Service]): un bean de contenedor, descubierto de la misma manera.EloquentRepositoryes la base respaldada por Eloquent defirefly/data, que implementa los puertos de CRUD/paginación (save(),findById(),findAll(),count(), …). Declararprotected string $modeles lo único que necesita un repositorio concreto — el resto viene del padre.findFirstByName(string $name)nunca se implementa — no hace falta. Es una consulta derivada:EloquentRepository::__call()analiza el nombre del método (DerivedQueryParser, análisis puro de cadenas, sin reflexión) enfind(prefijo) +First(límite 1, devuelve un único resultado anulable en vez de una lista) +Name(mapeado a la columnaname, operadorEqualsimplícito), y ejecutaGreeting::query()->where('name', '=', $name)->first(). El docblock@methodsolo está ahí para que PHPStan vea un retorno tipado para lo que, en tiempo de ejecución, es despacho dinámico.
Cablea el repositorio en el servicio, actualizando 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);
}
}Y expónlo, añadiendo un método store() a app/Http/GreetingController.php:
use App\Web\Dto\CreateGreetingRequest;
use Firefly\Web\Attributes\PostMapping;
use Firefly\Web\Attributes\RequestBody;
#[RestController]
final class GreetingController
{
// … el constructor y show() del Paso 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];
}
}con un DTO de petición sencillo, 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] decodifica el cuerpo JSON e hidrata CreateGreetingRequest a partir de él. Pruébalo:
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!"}Sin embargo, nada impide que se guarde un cuerpo vacío:
curl -X POST http://127.0.0.1:8000/greetings \
-H "Content-Type: application/json" \
-d '{"name": "", "message": ""}'
# {"name":"","message":""} <- ¡se guarda! todavía nada lo valida.Vamos a arreglar eso a continuación.
Añade restricciones a 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,
) {}
}Y protege el parámetro del controlador con #[Valid], en 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];
}
}Elemento por elemento:
#[NotBlank](deFirefly\Validation\Constraint) rechazanull,''y una cadena compuesta solo de espacios en blanco.#[Size(min: 2, max: 40)]acota la longitud de la cadena.#[Valid]sobre el parámetro#[RequestBody]pasa el cuerpo decodificado en bruto porFirefly\Validation\Constraint\BeanValidatorcontra las reglas de restricción compiladas del DTO antes de que se construya el DTO. Solo si tiene éxito se hidrataCreateGreetingRequest— un fallo lanza laValidationExceptiondel kernel y el cuerpo del método del controlador nunca llega a ejecutarse.
Repite la petición inválida:
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": "" }
]
}servida como application/problem+json, con un status 422 — la misma forma que cubre en detalle el
Paso 8. La petición válida del Paso 6 sigue funcionando exactamente
igual que antes.
Añade un endpoint de búsqueda para un greeting guardado, en 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];
}
}Toda excepción del framework extiende Firefly\Kernel\Exception\FireflyException, que fija un status HTTP,
un errorCode() estable y legible por máquinas, una ErrorCategory y una ErrorSeverity.
Firefly\Kernel\Exception\Business\ResourceNotFoundException es una subclase tipada 404/
RESOURCE_NOT_FOUND — nunca escribes un try/catch en el controlador. El
Firefly\Web\Exception\ProblemDetailsRenderer de firefly/web convierte cualquier FireflyException
lanzada en una respuesta application/problem+json, globalmente, en el httpStatus() propio de la
excepción.
Pruébalo:
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"
}No escribiste este renderer, ni una tabla de códigos de estado, ni una clase de respuesta de error — es un
único mecanismo consistente en todo el framework. Consulta Manejo de errores
para la taxonomía completa de excepciones (ConflictException, AuthenticationException,
AuthorizationException, y más).
Para una lectura, findRecord() del Paso 6 es suficiente. Pero una mutación — renombrar un greeting
guardado — es una buena candidata para firefly/cqrs: obtiene semántica transaccional y publicación de
eventos de dominio gratis.
Primero, enseña al modelo Greeting a levantar un evento de dominio al renombrarse. Actualiza
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 es la forma en trait del comportamiento de acumulación de eventos de
Firefly\Domain\AggregateRoot (raiseEvent()/pendingEvents()/pullEvents()/clearEvents()) para una
clase cuyo único slot de herencia ya está gastado en el propio Model de Eloquent — implementar
RecordsDomainEvents junto a él te da un único objeto que es a la vez una fila persistida y un agregado
que levanta eventos. (Un objeto de dominio puro, sin Eloquent, en cambio haría directamente
extend AggregateRoot — ver Dominio (DDD).)
Añade el evento, 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')] enruta este evento hacia el destino de evento de integración
greeting.events en cuanto se puentea hacia el bus de eda tras el commit (ver el
Paso 10).
Ahora el lado de escritura — un comando y su 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);
}
}Y el lado de lectura — una consulta y su 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);
}
}Elemento por elemento:
#[CommandHandler]/#[QueryHandler]especializan ambos a#[Component]— un único atributo a la vez registra el handler como bean de DI inyectado por constructor y le dice aHandlerScannercómo construir el manifiesto comando/consulta → handler. Cada handler expone exactamente un método públicohandle(); el tipo de mensaje manejado se infiere del único parámetro de ese método (pasa un class-string explícito,#[CommandHandler(RenameGreeting::class)], solo si necesitas desambiguar).#[Transactional]sobrehandle()es estructural, no decorativo: un proxy generado envuelve la llamada real en una transacción, y tras un commit exitoso vacía los eventos de dominio acumulados deGreetingy los publica — sin él,GreetingRenamednunca se levantaría hacia el bus de eda. Ver Transacciones.RenameGreetingHandlerno esfinal— el framework generaRenameGreetingHandler__FireflyTransactionalProxy extends RenameGreetingHandler, lo cual fallaría de forma fatal al cargar la clase si el padre fuesefinal.
Cablea ambos buses en el controlador y cambia showRecord() para que use QueryBus::ask(), actualizando
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];
}
}con un pequeño DTO adicional, 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 es el lado de escritura; QueryBus::ask(object $query): mixed
es el lado de lectura (ask, y no query, para no colisionar con el propio query() de Eloquent). Ambos
ejecutan un pipeline acotado (correlacionar → validar → autorizar → resolver e invocar el handler →
métricas) antes de llegar a tu handler. Pruébalo:
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 es un evento de dominio, levantado en proceso por Greeting::rename(). En cuanto se
confirma la unidad de trabajo #[Transactional] de RenameGreetingHandler, el puente
dominio → evento-de-integración de firefly/cqrs lo vuelve a publicar en el bus de broker de firefly/eda
como un evento de integración — eventType pasa a ser el nombre corto de la clase del evento
("GreetingRenamed"), y destination viene de #[PublishDomainEvent] ("greeting.events").
Añade un 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);
}
}Elemento por elemento:
#[Component]— un bean sencillo; el listener no necesita ningún estereotipo HTTP ni de CQRS, solo registro en el DI.#[EventListener('GreetingRenamed')]se suscribe al bus de broker de eda, no al bus de eventos de aplicación en proceso (ese es el#[AsEventListener]defirefly/context, algo no relacionado). El patrón se compara contra eleventTypedel envelope confnmatch()— una cadena plana como'GreetingRenamed'se comporta como una coincidencia exacta; también podrías pasar un glob ('Greeting*') o un array de varios nombres exactos. El patrón debe nombrar el tipo del evento, nunca la cadena de destino de#[PublishDomainEvent].EventEnvelopetransportaeventType,destination,payload(los campos públicos del evento —name,oldMessage,newMessage, más el propioeventId/occurredAtdeDomainEvent) yheaders.
Con el proveedor de eda en memoria por defecto del skeleton, la entrega es síncrona — el listener se
ejecuta antes de que la petición PATCH del Paso 9 siquiera devuelva su respuesta. Vuelve a lanzar el
PATCH del Paso 9 y comprueba el 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!", ...}Consulta Arquitectura orientada a eventos para el modelo completo de broker/adaptador y CQRS para ver exactamente cómo se dispara el puente dominio → evento-de-integración.
Cada clase que has añadido desde el Paso 1 — el repositorio, los handlers de comando/consulta, el listener de evento — necesita compilarse en los manifiestos de la app antes de que se recoja en un arranque cacheado. Vuelve a ejecutar:
php artisan firefly:cachefirefly:cache — wrote 16 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/fireflyEl recuento de manifiestos es fijo, dieciséis, porque ManifestCacheWriter escribe cada artefacto sin
condiciones — una aplicación sin ningún método #[Scheduled] obtiene igualmente un scheduled.php vacío,
que es lo que permite a la ruta de arranque leer "el fichero no está" como "nadie ha compilado todavía" y no
como "no hay nada que cargar". Este único comando ejecuta cada par escáner → compilador ya asentado de
cada paquete sobre config('firefly.scan.paths') y escribe, en bootstrap/cache/firefly/: los manifiestos
de DI/context/config-properties, la tabla de rutas compilada, el manifiesto de handlers
#[ControllerAdvice], el manifiesto de restricciones de validación, el manifiesto de handlers de CQRS, los
manifiestos de listeners de evento/mensaje, el manifiesto de tareas programadas, el manifiesto de orquestación
(#[Saga]/#[Tcc]/#[Workflow]), el manifiesto de event sourcing (#[EventType]), el manifiesto de métodos de
seguridad, el manifiesto de #[Transactional], el plan de proxies (proxy-plan.php — qué beans reciben
un proxy y qué advice ejecuta cada uno de sus métodos) y el classmap de proxies, y un fichero de clase
RenameGreetingHandler__FireflyTransactionalProxy generado (el "1 proxy" de arriba). Se emite un proxy por
cada clase que reclama el plan, que es toda clase nombrada por cualquier advice source — así que un
#[Service] que solo lleve #[PreAuthorize] recibe un proxy igual que uno #[Transactional]; en esta
aplicación RenameGreetingHandler resulta ser el único. Un FireflyCacheServiceProvider enlaza estos
manifiestos compilados antes de cualquier resolución de beans, de modo que toda petición posterior corre
contra simples arrays de PHP — sin reflexión en tiempo de ejecución en la ruta caliente. Vuelve a ejecutarlo
cada vez que añadas o cambies una clase anotada; php artisan firefly:clear borra la caché y vuelve al
escáner en proceso.
Ahora introspecciona la app en ejecución desde el terminal, en proceso — sin ida y vuelta HTTP:
php artisan firefly:health{
"status": "UP"
}firefly:health resuelve el mismo ActuatorRegistry que resolvería una petición HTTP
GET /actuator/health e imprime su respuesta — la CLI no reimplementa ninguna lógica propia de actuator.
Prueba también la ruta HTTP:
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 <- los endpoints sensibles no se exponen hasta que lo activasConsulta la Referencia de la CLI para cada comando firefly:* y generador make:firefly-*
(incluyendo make:firefly-handler, make:firefly-listener y make:firefly-repository, que generan
exactamente los estereotipos que acabas de escribir a mano), y Actuator para la
lista completa de endpoints y el modelo de exposición/seguridad.
En el Paso 10 el listener se ejecutó dentro de la petición PATCH, porque el bus de eventos del skeleton vive en
memoria: rápido, y perdido si el proceso muere entre el commit y el listener. El outbox de base de datos cierra ese
hueco sin que cambie una sola línea de tu código. El evento se escribe en una tabla en la misma transacción que
el greeting renombrado, y un worker lo entrega después — igual en SQLite, MySQL, MariaDB, PostgreSQL o SQL Server.
El proveedor es la primera clave del bloque eda de config/firefly.php:
'eda' => [
'provider' => env('FIREFLY_EDA_PROVIDER', 'memory'),
// …
],Cámbialo con la variable de entorno y crea la tabla del outbox — la migración se registra en cuanto el proveedor
es database:
echo 'FIREFLY_EDA_PROVIDER=database' >> .env
php artisan migrate # crea firefly_eda_outboxReinicia php artisan firefly:serve, vuelve a renombrar el greeting y mira el 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 # todavía la línea del Paso 10: nada ha entregado aún el evento nuevoEl evento GreetingRenamed es una fila PENDING en firefly_eda_outbox, confirmada junto con el mensaje nuevo.
Entrégalo con el consumidor — un worker de larga duración en producción, un solo mensaje aquí:
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!", ...}Qué cambió y qué no:
- Tu código no cambió.
GreetingRenamedListener,RenameGreetingHandlery#[PublishDomainEvent]están exactamente como los dejaron los Pasos 9 y 10. El outbox se engancha antes del commit, a través de la misma frontera#[Transactional]que publicaba el evento después de él. - La atomicidad sí. Si el handler lanza una excepción después de
rename(), la fila se revierte con el greeting: ningún evento para una escritura que nunca ocurrió, y — como la fila se confirma con el greeting — ningún evento perdido para una que sí ocurrió. - La entrega es un worker. La fila se arrienda a un consumidor antes de que se ejecute el listener, así que
puedes lanzar tantos workers
php artisan firefly:eda:consumecomo quieras. En SQLite la estrategia de reclamaciónautoes un arrendamiento optimista; en PostgreSQL, MySQL 8.0.1+ y MariaDB 10.6+ esFOR UPDATE SKIP LOCKED; en SQL Server,READPAST. Un worker que cae a mitad de una entrega deja su fila para que se entregue de nuevo cuando termine el arrendamiento (firefly.eda.database.lease-seconds, 300 por defecto) — así que mantén tus listeners idempotentes. - PostgreSQL puede hacerlo mejor.
FIREFLY_EDA_PROVIDER=postgreses el mismo outbox más unpg_notifyal escribir y un despertar porLISTENen el consumidor, de modo que la entrega es inmediata en lugar de por sondeo.
Consulta Outbox de base de datos de EDA para las estrategias de reclamación, el relay
opcional a RabbitMQ o Kafka (php artisan firefly:outbox:relay) y el componente de salud outbox, y la
Matriz de soporte de bases de datos para lo que hace cada servidor.
Hasta aquí todo ha cabido en una transacción. Vender una tarjeta de felicitación no cabe: reserva una tarjeta impresa del stock, cobra al cliente a través de un proveedor de pagos y entrega la tarjeta a un servicio de envío — tres sistemas, tres commits y ninguna transacción que los abarque. Si falla el envío, el cobro ya se ha hecho. La respuesta es una saga: cada paso confirma por su cuenta, cada paso que cambió algo nombra una compensación que lo deshace, y un fallo ejecuta las compensaciones de los pasos completados, del más reciente al más antiguo.
Los tres sistemas tienen un único sustituto, app/Cards/CardDesk.php — en una aplicación real cada uno sería un
adaptador detrás de su propio puerto; aquí una sola clase en memoria mantiene el paso centrado en la saga, y su
$journal registra cada llamada:
<?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}";
}
}Ahora la 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);
}
}Elemento a elemento:
#[Saga(name: 'greeting-card')]sobre un bean#[Service]declara la saga;firefly:cachela compila en el manifiesto de orquestación, y la topología se valida en el arranque.#[SagaStep(id: 'reserve', compensate: 'release')]— un paso hacia delante y el método que lo deshace. La compensación recibe el resultado del paso:release()recibe el id de tarjeta que devolvióreserve().dependsOn: ['reserve']ordena los pasos en capas:chargesolo se ejecuta después de quereservehaya tenido éxito, ysendsolo después decharge.#[FromStep('reserve')]le pasa asend()la tarjeta que reservó el primer paso;#[Input('to')]lee una clave de la entrada de la saga.sendno tiene compensación: es el último paso, así que nada posterior puede fallar y dejarlo colgado.
Exponla, 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() ejecuta la saga dentro de la petición y devuelve un SagaResult; una saga fallida se
convierte en una ConflictException, que la capa web renderiza como problem+json RFC-7807 exactamente igual que
los errores del Paso 8. Vuelve a compilar y prueba los dos casos:
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 con el código CARD_NOT_SENTEl segundo pedido reservó una tarjeta y cobró al cliente antes de que el envío fallara. El journal muestra lo que
pasó después, y el propio test del repositorio (tests/TutorialGreetingCardSagaTest.php) comprueba exactamente
este orden:
reserve card-1
charge payment-1 300
refund payment-1
release card-1El cobro se reembolsó antes de liberar la tarjeta — del más reciente al más antiguo, la política
strict-sequential que firefly.orchestration.saga.compensation-policy tiene por defecto. Por defecto los puntos
de control de la saga y sus cartas muertas viven en memoria, lo adecuado para una saga que termina dentro de una
petición. Define FIREFLY_ORCHESTRATION_PERSISTENCE=database y FIREFLY_ORCHESTRATION_DLQ_STORE=database
(después de php artisan vendor:publish --tag=firefly-orchestration-migrations y php artisan migrate) y cada
frontera de paso se escribe en firefly_orchestration_executions, un pedido fallido acaba en
firefly_orchestration_dead_letters, y php artisan firefly:orchestration:list --name=greeting-card y
php artisan firefly:orchestration:dlq --name=greeting-card los muestran. Consulta
Orquestación para las cinco políticas de compensación, TCC, los workflows duraderos
que esperan señales y temporizadores, la recuperación y los comandos de operación.
El Paso 6 guardó cada greeting como una fila, y el renombrado del Paso 9 la sobrescribió: la tabla conoce el mensaje actual y nada de los anteriores. El event sourcing conserva esa otra mitad. En este paso cada greeting que envías a un nombre se registra como un evento en un almacén de eventos, un pequeño agregado se reconstruye a partir de esos eventos en cada petición, y una proyección mantiene al día un modelo de lectura — cuántas veces se ha saludado a cada nombre, y el último mensaje — desde un punto de control.
El proveedor del almacén de eventos está en el bloque eventsourcing de config/firefly.php:
'eventsourcing' => [
// …
'store' => [
'provider' => env('FIREFLY_EVENTSOURCING_STORE', 'memory'),
'connection' => env('FIREFLY_EVENTSOURCING_DB_CONNECTION'),
'batch-size' => 500,
],
// …
],memory lo olvida todo cuando termina el proceso; pásalo a la base de datos:
echo 'FIREFLY_EVENTSOURCING_STORE=database' >> .envPrimero el evento, app/Greetings/GreetingSaid.php. #[EventType] le da el nombre estable que guarda el almacén:
<?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);
}
}Después el agregado, app/Greetings/GreetingJournal.php — un diario por nombre, cuyo id es el propio nombre:
<?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;
}
}Su repositorio, app/Greetings/GreetingJournals.php, es un único 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);
}
}El modelo de lectura es una tabla corriente. Crea 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');
}
};y la proyección que la rellena, 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 : ''];
}
}Por último los endpoints, app/Http/GreetingJournalController.php — el POST escribe a través del agregado y el
GET lee la proyección:
<?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.");
}
}Elemento a elemento:
#[EventType(name: 'greeting.said')]da nombre al evento para el almacén;firefly:cachelo compila, y una clase de evento sin él se rechaza al hacer append, así que no se guarda nada que no se pueda volver a leer.GreetingJournalno tiene fila.recordThat()aplica el evento en el acto y lo deja en el búfer;applyGreetingSaid()es el único código que cambiatimesylastMessage, así que reproducir los eventos reconstruye el mismo diario.GreetingJournals::find()reproduce los eventos de un nombre (nullcuando no hay ninguno);save()añade los nuevos bajo la versión que cargó — dos peticiones que saludan a Ada desde la misma versión no pueden ganar las dos: la segunda recibe un 409ConcurrencyException.GreetingCountses un#[Component], así que el ejecutor de proyecciones lo encuentra. Lee el flujo global desde su punto de control, y sus filas y el punto de control se confirman en una sola transacción sobre la misma conexión.
Vuelve a compilar, migra y saluda dos veces a Ada:
php artisan firefly:cache
php artisan migrate # las tablas del almacén de eventos y 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: nada ha proyectado aún los greetingsLos dos eventos están en firefly_eventsourcing_events; el modelo de lectura no ha oído hablar de ellos. Ponlo al
día:
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!"}El propio test del repositorio (tests/TutorialGreetingJournalTest.php) hace exactamente estas peticiones y
ejecuta exactamente este comando. En producción mantén php artisan firefly:eventsourcing:projections:run --live
en marcha bajo un supervisor de procesos; php artisan firefly:eventsourcing:projections muestra el punto de
control y el retraso de cada proyección, y php artisan firefly:eventsourcing:projections:rebuild greeting.counts
tira el modelo de lectura y reproduce en él cada evento — el test borra greeting_counts y lo recupera así.
Consulta Event Sourcing para las instantáneas, los upcasters, la publicación de los
eventos añadidos por el outbox y el almacén de orquestación con event sourcing, y el Capítulo 9B del
libro para todos ellos sobre el sample de Lumen.
Has construido una pequeña porción de funcionalidad — #[RestController] → #[Service] →
#[ConfigProperties] → #[Repository] → #[Valid] → errores RFC-7807 →
#[CommandHandler]/#[QueryHandler] → #[EventListener] → un outbox de base de datos → una #[Saga] →
un diario #[EventType] y su proyección → un arranque cacheado, sin reflexión — usando
únicamente los atributos reales y ya distribuidos del propio framework. Desde aquí:
- Inyección de dependencias — toda la familia
#[Component], los scopes, los beans condicionales y cómo funciona el manifiesto compilado. - Configuración — perfiles,
#[Value]y DTOs#[ConfigProperties]anidados. - Validación — el catálogo completo de restricciones (incluidas las reglas del dominio financiero).
- Capa web — el binding de parámetros (
#[QueryParam],#[RequestHeader],#[UploadedFile]), la negociación de contenido y el modelo completo de cascada de#[Valid]. - Datos y repositorios — la gramática completa de consultas derivadas,
#[Query], lasSpecificationy la paginación. - Datos relacionales y Transacciones — todo lo
que hacen
EloquentRepositoryy#[Transactional]más allá de este tutorial (modos de propagación, aislamiento, reglas de rollback). - Dominio (DDD) —
Entity,ValueObject,AggregateRoot, y el modelo completo de eventos tras el commit. - Manejo de errores — la taxonomía completa de excepciones y
#[ExceptionHandler]/#[ControllerAdvice].
- CQRS (comando/consulta) — el pipeline acotado, el puente dominio → evento-de-integración en detalle, y los mecanismos de autorización/caché.
- Arquitectura orientada a eventos — la política de reintento/DLQ, el adaptador de cola,
y la distinción entre las dos superficies de evento (
#[AsEventListener]frente a#[EventListener]). - Brokers de EDA y Mensajería — adaptadores de broker
reales y el
MessageBrokerPortde bajo nivel, de bytes en bruto. - Outbox de base de datos de EDA — el outbox portable del Paso 12: las estrategias de reclamación, el relay y la matriz de soporte de bases de datos.
- Orquestación — sagas, TCC y workflows duraderos: todo lo que el Paso 13 dejó fuera.
- Webhooks y Callbacks — webhooks firmados de entrada, callbacks firmados de salida, ambos sobre el bus de eventos.
- Event Sourcing — el almacén de eventos del Paso 14, más instantáneas, upcasters, la publicación por el outbox y las sagas guardadas como flujos de eventos.
- Seguridad — autenticación persistida en sesión, login por formulario, HTTP Basic,
JWT, reglas de URL
HttpSecuritycon denegación por defecto y#[PreAuthorize]sobre cualquier bean estereotipado. - Cliente OAuth2 y Servidor de autorización OAuth2 — iniciar sesión con Google, GitHub, Okta, Keycloak o Entra, y emitir tus propios tokens.
- Actuator y Observabilidad — el conjunto completo de endpoints y el núcleo de métricas al estilo Prometheus/Micrometer.
- Trazas y Logging — un
traceparentW3C propagado por HTTP, por los buses de CQRS y por el bus de eventos, y los mismos identificadores en cada registro de log estructurado. - Resiliencia, Planificación y
Bloqueos de planificación en base de datos —
Retry/CircuitBreaker/Bulkhead,#[Scheduled]y el bloqueo que mantiene una tarea en un solo nodo. - Testing — el arnés de arranque de
firefly/testingy los dobles de grabación para cada puerto usado en este tutorial, y Pruebas de integración para los mismos tests sobre servidores reales.
El sample de Lumen es
una porción vertical ejecutable de wallet digital y ledger que ejercita exactamente los mismos primitivos
que acabas de usar — #[Transactional], CQRS, eventos de dominio sobre EDA, seguridad a nivel de método y
renderizado de errores RFC-7807 — a la escala de un agregado completo con múltiples comandos, consultas y
un proyector de modelo de lectura.
Apache-2.0 © Firefly Software Solutions Inc.