Spring Boot's cohesion, native to Laravel 13.
Dependency injection with stereotypes, conditional auto-configuration, hexagonal ports & adapters, CQRS, event-driven architecture with a genuine same-transaction outbox, Spring-Security-shaped method security on any bean, both halves of OAuth2, and OpenTelemetry-shaped tracing — wired the moment you install a package, with your own beans always winning.
📘 Book · Quickstart · Why · Architecture · Patterns · Modules · Docs · Changelog
Table of contents
- 📘 The Book — LaraFly by Example
- Why LaraFly?
- Quickstart
- Philosophy
- Architecture — Boot Pipeline · DI & Auto-Configuration · Request Lifecycle · CQRS ⟷ EDA Bridge · Same-Tx Outbox · Security Filter Chain · Signing in with OAuth2 · Interception · Trace Context
- Featured Patterns
- Installation
- CLI & Project Scaffolding
- Modules
- Documentation
- Roadmap
- Firefly Framework Ecosystem
- Requirements
- Contributing
- License
LaraFly by Example is the official, project-driven book for the framework — a PHP sibling to
PyFly by Example. It builds Lumen, the
wallet-and-ledger service in samples/lumen/, from an empty directory into a secured,
event-driven, actuator-observed microservice, chapter by chapter — every listing drawn from that real project
(its boot and test suite are verified in CI against the same framework source).
Download the book in English or Spanish, as PDF or EPUB. The published editions include the single-package installation and bundled installer.
The book is structurally complete and bilingual (English + Spanish): a quick start, fifteen chapters
across four parts — Foundations (DI, config, HTTP), Modelling & Persisting the Domain (repositories, DDD),
Coordinating & Securing the App (CQRS, EDA + transactional outbox, #[Transactional], security, OAuth2 and
OpenID Connect), and Observability, Testing & Delivery (actuator, testing, the CLI + zero-reflection cache)
— plus a Laravel→LaraFly cheat-sheet and a glossary. The two editions are line-for-line the same book: every
chapter file has the same sections, in the same order, at the same line numbers, and every php listing and
every source:-marked excerpt in the Spanish edition is the English one character for character — the only
thing translated inside a fence is a shell or tree comment, which is a sentence the reader reads rather than
code they run. Every fenced PHP listing in both manuscripts carries a source: marker naming the
repository file it was excerpted from — compared against it line for line by tests/DocsCodeIsRealTest.php —
or an illustrative: marker saying it is the reader's own code, which is what php -l checks. The
sources live under
book/ (EN manuscript · ES manuscript) and build to PDF + EPUB
in both languages via book/build/run.sh. samples/lumen/ is the fastest way to see the
whole stack fit together end to end, and the Featured Patterns section below walks
through its most important pieces.
Laravel gives you a superb HTTP kernel, ORM, and ecosystem — but it doesn't tell you how to structure a non-trivial application. Where does business logic live? How do controllers stay thin? How do you keep Eloquent out of your domain model? How do you make a wallet transfer and its audit-log write commit or roll back together? How do you stop an unauthenticated request from calling an admin-only command bus handler? Every team answers these questions differently, and every answer has to be re-learned by the next engineer who joins.
Laravel gives you infinite flexibility. What it doesn't give you is a shared architecture.
LaraFly is a cohesive, hexagonal application layer on top of Laravel 13 — the PHP member of the
Firefly Framework family, built to feel exactly like Spring Boot to
anyone who has used it: PHP-8-attribute dependency injection with stereotypes, conditional auto-configuration
that backs off the moment you supply your own bean, a CQRS command/query bus, event-driven architecture with a
genuine same-transaction outbox (not a "publish and hope"), declarative #[Transactional] transaction
demarcation, deny-by-default method and URL security, and a production-ready actuator/observability surface —
all compiled ahead of time into bootstrap/cache/firefly/ for a zero-reflection boot.
#[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);
}
}#[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)];
}
}No service-provider boilerplate, no manual route registration: the component scanner finds GreetingService
and GreetingController, the container autowires GreetingProperties into the service by constructor type,
and the route scanner compiles #[GetMapping('/greetings/{name}', name: 'greetings.show')] into the route
table. php artisan firefly:cache compiles all of that ahead of time for a reflection-free boot; without it
the same scan simply runs in-process at boot instead, so the app behaves identically either way. See
Featured Patterns below for the full CQRS, EDA, outbox, and security tour, drawn from the
runnable samples/lumen/ wallet-ledger sample.
LaraFly is not a fork of Laravel and does not hide it — every package layers cleanly on top of
Illuminate\Container, Eloquent, the HTTP kernel, and the queue/cache/scheduler, so everything you already
know about Laravel keeps working underneath.
Three capabilities in that list are recent enough — and far enough from what teams assume a PHP framework will have — that they are worth naming outright:
- Both halves of OAuth2, as two packages you install.
firefly/security-oauth2-clientturns "Sign in with Google" into a pair of ordered filters and a configuration block — presets for Google, GitHub, Okta, Keycloak and Entra, OpenID Connect discovery for anything else, PKCE, id-token validation against the issuer's JWKS, anOidcUserinjected straight into your controller action, RP-initiated logout, the client-credentials grant and anHttp::oauth2Client('…')macro for calling a downstream API as the registration.firefly/security-oauth2-serverpoints the same protocol the other way and makes your application the provider: registered clients held in memory or Eloquent,/oauth2/authorizewith PKCE and a consent page,/oauth2/tokenwith three grants, introspection, revocation, userinfo, JWKS, both.well-knowndocuments, and RS256/ES256 signing keys generated byphp artisan firefly:oauth2:keys— rotated by moving the old one tofirefly.security.oauth2.server.jwt.previous_keys, so tokens in flight keep verifying until they expire. - Traced and logged like a service, not a script. A
Tracer/Spanport whose shipped default is aNoOpTracer— nothing is recorded, nothing is paid for — and whose OpenTelemetry adapter binds itself the moment the SDK is installed andfirefly.observability.tracing.enabledis on. One W3Ctraceparentis continued at the server filter and carried across five boundaries from there: inbound HTTP, both CQRS buses, the in-memory and queue event buses on the way out, the same buses on delivery, and every outboundHttpcall. The same trace and span ids land on every log line through a Monolog processor, in plain text or injson,ecsorlogstash. - Spring Data's vocabulary on
firefly/data. Derived query methods,#[Query], query by example,#[Modifying],#[Projection],#[Lock],#[EntityGraph], andPage/Slice/Pageable/Sort— plus aDataAccessExceptionfamily underFirefly\Kernel\Exception\Infrastructure\that aPersistenceExceptionTranslatorproduces from the driver's own SQLSTATE. A unique-index collision stops being aQueryExceptionyou have to string-match and becomes aDuplicateKeyExceptioncarrying a 409 and a fixed sentence, with the driver's message — which has the statement and its bindings interpolated into it — left onprevious, for the log and never for the wire.
- Laravel developers who want enterprise-grade architecture — DI stereotypes, hexagonal ports, CQRS, an event bus with a real outbox — without assembling it package by package.
- Teams who want every service in the org to share one convention instead of reinventing structure per project.
- Architects building polyglot platforms who need consistency across Java, Python, and PHP services.
- Anyone coming from Spring Boot who wants the same mental model expressed natively in PHP — see the Laravel ↔ Spring Boot comparison guide.
# 1 · Install the global installer once, then scaffold a new app
composer global require fireflyframework/larafly
firefly new my-app
cd my-app
# 2 · Compile the app for a zero-reflection boot (idempotent — create-project already ran this once)
php artisan firefly:cache
# 3 · Run it
php artisan firefly:servefirefly new wraps composer create-project firefly/skeleton (git-init included by default), which already
wires a #[Controller] welcome page, a sample #[RestController]/#[Service] pair, sqlite for storage, and a
post-create-project-cmd hook that ran firefly:cache for you — so the app is already booting
reflection-free. Re-run firefly:cache any time you add or change a
#[Component]/#[RestController]/#[CommandHandler]/etc. class; firefly:clear drops back to the
in-process scan, which is slower but functionally identical. See Installation for the
non-global-installer path and CLI & Project Scaffolding for the full command
reference.
Four principles shape every design decision in LaraFly.
A new capability package should work the moment it's installed. firefly/autoconfigure discovers each
package's #[Configuration] classes, and a #[ConditionalOnMissingBean]-guarded #[Bean] backs off silently
the instant you register your own — "default, with override," the same contract Spring Boot's auto-configuration
gives you.
Business logic should never use Illuminate\Database\Eloquent\Model directly if it can help it. LaraFly
enforces hexagonal architecture — ports and adapters — across every capability that touches storage or
transport:
interface WalletRepository
{
public function save(Wallet $wallet): Wallet;
public function findById(string $id): ?Wallet;
/** @return list<Wallet> */
public function findByOwnerId(string $ownerId): array;
}#[Repository]
final class EloquentWalletRepository extends EloquentRepository implements WalletRepository
{
protected string $model = Wallet::class;
// …
}save(), findById() and findByOwnerId() each carry an explicit body in the adapter — implements does not
accept EloquentRepository's magic __call() dispatch — and application and command-handler code still depends
only on WalletRepository; the container's nominal interface-binding wires EloquentWalletRepository in behind
it automatically. Deptrac enforces the boundary at the monorepo level — a domain package that imports
Illuminate\Database\* fails the architecture gate.
Every public surface is typed and analysed at PHPStan max strictness — no mixed left un-narrowed, no
@phpstan-ignore as a substitute for a real fix. Framework behaviour is declared with PHP 8 attributes
(#[Service], #[RestController], #[CommandHandler], #[Transactional], #[PreAuthorize], ...), scanned
once and compiled to a cached manifest — never re-reflected on a production request.
firefly/security's HTTP rule chain is deny-by-default: an unmatched URL is denied, not silently allowed.
firefly/actuator exposes health and info and nothing else: every other endpoint it ships answers
404 until you name it in firefly.management.endpoints.web.exposure.include. A cached
firefly:cache boot is the supported way to run in production — reflection only ever happens once, at build
time, never per request.
LaraFly is hexagonal end to end: every capability exposes a nominal PHP interface (a port) with one or more adapters, domain/application code depends only on ports, and Deptrac enforces the boundary at the monorepo level.
Every capability package plugs into one shared FireflyKernel through a FireflyServiceProvider: a
provider's register() only buffers its BootPass contributions into a PendingBootPasses collector — it
never resolves the kernel itself, so Laravel's alphabetical package-discovery order can never affect boot
order. The kernel then drains that buffer and drives the real, phased order:
The DI container (firefly/container) resolves dependencies from type hints discovered by a component
scan — no XML, no service locators. The three classes below are the container package's own test fixtures, so
every claim made about them is exercised by packages/container/tests:
interface Greeter
{
public function greet(): string;
}#[Service]
#[Primary]
#[Order(10)]
final class EnglishGreeter implements Greeter
{
public function greet(): string
{
return 'Hello';
}
}#[Service('spanish')]
#[Qualifier('spanish')]
#[Order(20)]
final class SpanishGreeter implements Greeter
{
public function greet(): string
{
return 'Hola';
}
}$container->get(Greeter::class); // EnglishGreeter (the #[Primary] one)
$container->getByName('spanish'); // SpanishGreeter
$container->getAll(Greeter::class); // every implementation, sorted by #[Order]firefly/autoconfigure layers Spring-Boot-style starters on top: a candidate #[Configuration] class is
discovered at phase 200, its bean definitions are committed to the registry at phase 500 tagged
DefinitionSource::AutoConfiguration, and a #[ConditionalOnMissingBean(X)]-guarded #[Bean] only survives
back-off at phase 600 if nothing else already registered an X — so your own bean always wins, regardless of
provider load order:
An HTTP request flows through the Spring-style filter chain (firefly/web), into a #[RestController]
action, through argument-resolved, #[Valid]-checked parameters, into the CQRS bus, through a
#[CommandHandler]/#[QueryHandler], into a repository port and its Eloquent adapter, and back — with any
raised domain events drained after commit:
firefly/cqrs's bus wraps every handler call in a bounded pipeline (correlate → validate → authorize → invoke
→ metrics) and re-throws any fault as a category-preserving CommandProcessingException/
QueryProcessingException. After a #[Transactional] handler's unit of work commits, the same bridge
republishes each raised DomainEvent onto the firefly/eda bus as an integration event — closing the
domain → integration-event loop with no glue code in application handlers:
The default memory/queue firefly/eda providers publish only after the aggregate's transaction commits —
never atomically with it. firefly/eda-postgres closes that gap: with firefly.eda.provider=postgres, the
outbox row is written inside the aggregate's own open transaction, on the aggregate's own connection, so it
commits or rolls back atomically with the aggregate — no dual-write, no "commit then hope the publish
succeeds":
See the same-tx outbox showcase below for the config and the seam that makes this possible.
Everything firefly/security does to a request happens in one ordered WebFilter chain, sitting on Laravel's
own global middleware stack. FilterChainRegistrar::orderedFilters() prepends RequestContextFilter and
CorrelationIdFilter unconditionally, then sorts every other filter bean by the #[Order] its
ComponentDescriptor carries — the manifest's number, never a resolved instance's — and breaks ties with
strcmp on the class name, which is why HttpExchangeFilter precedes MetricsFilter at the same -100.
Authentication is not one filter but five — form login (-92), HTTP Basic (-91), JWT (-90), the OAuth2
resource server (-85) and remember-me (-83) — each writing into the same SecurityContext, with the two
OAuth2 packages adding three more filters of their own when installed. HttpSecurityFilter (-70) has the
last word, applying the deny-by-default URL rules first-match-wins. When it denies an anonymous request it
does not simply throw — it asks the DelegatingAuthenticationEntryPoint, whose default auto mode sends a
browser to the login page, an HTTP-Basic-configured API a WWW-Authenticate challenge, and everything else
the 401 problem document:
firefly/security-oauth2-client and firefly/security-oauth2-server are the two ends of the same protocol,
and only configuration decides whether an application is one, the other, or both. The figure follows a single
sign-in across all three parties: a protected GET is denied by HttpSecurityFilter, saved in the session and
redirected to the login page; the "Sign in with …" button hits
OAuth2AuthorizationRequestRedirectFilter (-89), which stores a single-use state, a nonce and a PKCE
verifier before redirecting the browser out to the authorization server; that server's own
OAuth2AuthorizationServerFilter (-82) matches /oauth2/authorize, signs the person in on its own login
page, shows the consent page, and mints a single-use code; and the code comes back through
OAuth2LoginAuthenticationFilter (-88) at /login/oauth2/code/{id}, which exchanges it on the back channel
and verifies the id token against the issuer's JWKS. Every path in the picture is a real default read out of
OAuth2ClientSettings and AuthorizationServerSettings, not an illustration of the RFC:
#[Transactional] and #[PreAuthorize] do not each get their own interception mechanism — there is exactly
one, and it is a port. A package contributes a kind of advice by shipping a single #[Component] that
implements AdviceSource (advice(), scan($psr4), render($row)); firefly/data ships
TransactionalAdviceSource and firefly/security ships MethodSecurityAdviceSource. ProxyPlanner merges
every source's rows into one ProxyPlan, ProxyPlanCompiler var_exports that plan into proxy-plan.php,
and ProxyClassGenerator emits exactly one final class Foo__FireflyTransactionalProxy extends Foo per bean
— carrying one interceptor property and one descriptor factory per advice kind the class actually uses,
with the descriptors baked in as literals so the runtime never looks anything up. At runtime
MethodInvocation::proceed() walks that list, and because lower advice order runs outer — security at
100, the transaction at 1000 — a refused #[PreAuthorize] throws before a transaction has been opened. That
is a property of the compiled plan, not a convention anyone has to remember:
TracingFilter (-110) is the outermost ordered filter in the chain. It asks
W3CTraceContextPropagator::extract() for the inbound traceparent, starts a SERVER span with that remote
context as its parent (or a new root when there is none), and publishes the resulting ids onto Laravel's
Context and Request::$attributes as firefly.trace_id and firefly.span_id — the one place everything
downstream reads them from. That is the first of five boundaries the same trace crosses; the other four are
an INTERNAL span per command and per query at the CQRS seam, a PRODUCER span stamping traceparent into
the EDA envelope's headers on the in-memory and queue buses, a CONSUMER span on every delivery — a broker's
included, through the shared SubscriberRegistrySink, because the broker publishers build their own envelopes
and do not reach the publish seam yet (Known-latent) — and a CLIENT
span on every outbound Http call, which injects the header again so the next service's own TracingFilter
continues the same trace. The ids then land in three places you can actually read: every log line,
/actuator/httpexchanges, and the admin dashboard. None of it costs anything until you opt in — the shipped
default is NoOpTracer, and every instrumentation site checks $span->context()->isValid() before
publishing an id:
Twelve showcases below. The eleven that carry code are each an accurate snippet lifted straight from
samples/lumen/ (the wallet-and-ledger sample), the skeleton's own configuration reference, or the framework
itself — no invented API, and every attribute and class shown in them compiles against the shipped 26.09.3
release. The twelfth is the two browser surfaces, which are pages you open rather than code you write, so it
carries prose and links instead of a listing.
That is a checked claim, not a promise — and here is exactly how far it reaches. Every PHP listing below
carries an HTML comment naming the file it was copied from, and tests/DocsCodeIsRealTest.php fails the build
unless the listing appears verbatim in that file. A line that is exactly // … is the one permitted cut —
it means "whole lines omitted here" and nothing else. The handful of listings that show code you write,
which therefore exists in no file of this repository, are marked illustrative instead and are still linted and
resolved against the real class names.
The shell listings are the exception: there is no file to copy a command line from, so they carry no marker
and the same test holds them to what they assert instead — every php artisan firefly:* /
make:firefly-* command must be a $signature the framework really declares, every composer <script> a
script composer.json or skeleton/composer.json really defines, and every firefly.* key a key the
framework really reads.
use Firefly\Container\Attributes\Service;
// …
#[Service]
final class GreetingService
{
public function __construct(private readonly GreetingProperties $properties) {}
public function greet(string $name): string
{
return sprintf('%s, %s!', $this->properties->salutation, $name);
}
}A #[Service] is a specialisation of #[Component] — registered as a container singleton by a scan that finds
every class carrying it, with its GreetingProperties constructor dependency autowired by type.
Highlights: stereotypes, scopes, #[Primary]/#[Qualifier], #[Order] — see
Dependency Injection.
#[RestController]
#[RequestMapping('/api/v1/wallets')]
final class WalletController
{
public function __construct(
private readonly CommandBus $commands,
private readonly QueryBus $queries,
) {}
/** @return array{wallet_id: string} */
#[PostMapping(status: 201)]
public function open(#[Valid] #[RequestBody] OpenWalletRequest $body): array
{
/** @var string $id */
$id = $this->commands->send(new OpenWallet($body->owner_id, Currency::from($body->currency)));
return ['wallet_id' => $id];
}
// …
/** @return array{wallet_id: string, balance_minor: int} */
#[GetMapping('/{id}/balance')]
public function balance(#[PathVariable] string $id): array
{
/** @var int|null $balance */
$balance = $this->queries->ask(new GetBalance($id));
if ($balance === null) {
throw new ResourceNotFoundException("Wallet {$id} not found");
}
return ['wallet_id' => $id, 'balance_minor' => $balance];
}
// …
}A thin HTTP-onto-CQRS mapping: every action builds a command/query, dispatches it through the bus, and shapes
the response array. A domain fault (an unknown wallet, an overdraw, a denied #[PreAuthorize]) surfaces as an
RFC-7807 problem+json response from the framework's global renderer — no local #[ExceptionHandler] needed.
Highlights: #[GetMapping]/#[PostMapping], #[PathVariable]/#[RequestBody], the web filter chain — see
Web Layer.
#[CommandHandler]
class OpenWalletHandler
{
public function __construct(private readonly WalletRepository $wallets) {}
#[Transactional]
public function handle(OpenWallet $command): string
{
$id = 'wlt-'.bin2hex(random_bytes(8));
$wallet = Wallet::open($id, $command->ownerId, $command->currency);
$this->wallets->save($wallet);
return $id;
}
}The handled command type is inferred from the sole handle() parameter — no explicit
#[CommandHandler(OpenWallet::class)] needed. #[CommandHandler]/#[QueryHandler] both specialise
#[Component], so the handler is a constructor-injected DI bean automatically. Highlights: the bounded
command/query pipeline (correlate → validate → authorize → invoke → metrics), category-preserving exception
wrapping — see CQRS.
final class Wallet extends Model implements RecordsDomainEvents
{
use HasDomainEvents;
// …
public static function open(string $id, string $ownerId, Currency $currency): self
{
if (trim($ownerId) === '') {
throw new ConflictException('owner_id is required');
}
$wallet = new self([
'id' => $id,
'owner_id' => $ownerId,
'currency' => $currency->value,
'balance_minor' => 0,
]);
$wallet->raiseEvent(new WalletOpened($id, $ownerId, $currency->value));
return $wallet;
}
// …
}#[PublishDomainEvent('wallet.events')]
final readonly class WalletOpened extends DomainEvent
{
public function __construct(
public string $walletId,
public string $ownerId,
public string $currency,
) {
parent::__construct();
}
}raiseEvent() (from the HasDomainEvents trait) buffers the event on the aggregate; #[Transactional]'s
generated proxy is the sole caller of DomainEventDispatcher::dispatchAfterCommit(), so the event only
publishes once the enclosing unit of work actually commits — never on a rolled-back write. Highlights:
Entity/ValueObject/AggregateRoot, derived queries, specifications — see
Domain (DDD) and Data & Repositories.
#[Component]
final class LedgerProjector
{
#[EventListener(['WalletOpened', 'FundsDeposited', 'FundsWithdrawn', 'TransferCompleted'])]
public function onWalletEvent(EventEnvelope $envelope): void
{
// …
$walletId = $envelope->payload['walletId'] ?? $envelope->payload['sourceWalletId'] ?? '';
$amountMinor = $envelope->payload['amountMinor'] ?? 0;
$balanceMinor = $envelope->payload['balanceMinor'] ?? 0;
LedgerEntry::query()->create([
'wallet_id' => is_string($walletId) ? $walletId : '',
'event_type' => $envelope->eventType,
'amount_minor' => is_int($amountMinor) ? $amountMinor : 0,
'balance_minor' => is_int($balanceMinor) ? $balanceMinor : 0,
'occurred_at' => now(),
]);
}
}#[EventListener] enumerates event-type names (matched with fnmatch against $envelope->eventType), not
the #[PublishDomainEvent] destination — a common gotcha the sample's own docblock calls out explicitly. The
sourceWalletId fallback is not defensive padding either: TransferCompleted names its wallet
sourceWalletId rather than walletId, so without that fallback every completed transfer would project under
an empty wallet id. This projector turns committed wallet events into an append-only ledger_entries read
model. Highlights: the in-memory/queue adapters, retry + DeadLetterStore, and why #[AsEventListener]
(in-process) and #[EventListener] (the broker bus) are two distinct surfaces — see
Event-Driven Architecture.
The reference configuration ships both halves already: the provider switch and, commented out, the outbox
block it turns on. Point the switch at postgres (FIREFLY_EDA_PROVIDER=postgres, or the literal string) and
uncomment the postgres section:
'eda' => [
'provider' => env('FIREFLY_EDA_PROVIDER', 'memory'),
// …
// 'postgres' => [
// 'connection' => 'pgsql',
// 'channel' => 'firefly_eda_events',
// 'max_attempts' => 3,
// 'relay' => [
// 'downstream_provider' => 'rabbitmq',
// ],
// ],
// …
],composer require firefly/eda-postgres
php artisan migrate # creates firefly_eda_outbox
php artisan firefly:eda:consume # terminal in-process delivery, run as a long-lived workerNo application code changes — the same #[EventListener] handlers and the same EventPublisher::publish()
call sites now run against a durable, same-transaction outbox instead of memory/queue. The seam is a small
PreCommitEventHook interface firefly/data's DomainEventDispatcher calls (if bound) before the ordinary
after-commit dispatch — null by default, so installing the package without selecting provider=postgres is
fully inert. Highlights: pg_notify/LISTEN low-latency wake, FOR UPDATE SKIP LOCKED claiming, the
optional firefly:outbox:relay to a distinct downstream broker — see EDA Brokers.
#[CommandHandler]
class TransferHandler
{
public function __construct(private readonly WalletRepository $wallets) {}
#[Transactional(propagation: Propagation::REQUIRED)]
public function handle(Transfer $command): void
{
$source = $this->wallets->findById($command->sourceWalletId)
?? throw new ResourceNotFoundException("Wallet [{$command->sourceWalletId}] not found");
$destination = $this->wallets->findById($command->destinationWalletId)
?? throw new ResourceNotFoundException("Wallet [{$command->destinationWalletId}] not found");
$amount = new Money($command->amountMinor, $source->currency());
$source->withdraw($amount); // debit (raises FundsWithdrawn)
$this->wallets->save($source); // persist + track the debit INSIDE the tx, so it can genuinely roll back
$destination->deposit($amount); // credit — throws on currency mismatch -> whole tx rolls back
$this->wallets->save($destination);
$source->recordTransferTo($command->destinationWalletId, $amount); // both legs succeeded -> raise TransferCompleted
// commit here -> FundsWithdrawn + FundsDeposited + TransferCompleted drain atomically after the unit of work commits.
// (recordTransferTo runs only on the success path: a failed credit throws above, the tx rolls back, nothing publishes.)
}
}#[Transactional] is load-bearing, not cosmetic: a proxy generated at scan time drives manual
DB::beginTransaction()/commit()/rollBack() boundaries (never DB::transaction($closure), so a caught
exception can be committed-and-rethrown when it matches noRollbackFor). The debit is persisted inside the
transaction before the credit runs, so a currency mismatch on the credit leg genuinely rolls the debit back too
— there is no path where the source loses funds the destination never receives. Highlights: the seven
Spring propagation modes (including NESTED over Laravel savepoints), rollbackFor/noRollbackFor — see
Transactions.
#[CommandHandler]
class WithdrawHandler
{
public function __construct(private readonly WalletRepository $wallets) {}
#[PreAuthorize("hasRole('ADMIN') or hasRole('WALLET_OWNER')")]
#[Transactional]
public function handle(Withdraw $command): int
{
$wallet = $this->wallets->findById($command->walletId)
?? throw new ResourceNotFoundException("Wallet [{$command->walletId}] not found");
$wallet->withdraw(new Money($command->amountMinor, $wallet->currency()));
$this->wallets->save($wallet);
return $wallet->balanceMoney()->minorUnits;
}
}SecurityCommandAuthorizer enforces this before the handler runs, at the bus — a denied withdraw surfaces
as a CommandProcessingException wrapping an AuthorizationException (403), never a silent no-op. The same
rules hold on the controller dispatcher and on any stereotyped bean, through the one compiled proxy chain
#[Transactional] already used — so a #[Service] method carrying #[PreAuthorize], #[PostAuthorize],
#[PreFilter] or #[PostFilter] is guarded wherever it is called from, and a refusal never opens a
transaction. The expression evaluator is a closed, no-eval whitelist tokenizer (hasRole, hasAnyRole,
hasAuthority, hasAnyAuthority, hasScope, hasAnyScope, hasPermission, isAuthenticated, permitAll,
denyAll, #param references only). Highlights: the deny-by-default HttpSecurity URL DSL,
form/basic/session login with remember-me, JwtService/OAuth2 resource server, CSRF + security headers — see
Security.
composer require firefly/security-oauth2-client, then fill in a registration. The reference configuration
ships the whole block already, commented out — a preset registration needs two lines, and a provider the
presets do not know needs an issuer_uri for discovery to do the rest:
'client' => [
'enabled' => env('FIREFLY_OAUTH2_CLIENT_ENABLED', false),
'login' => [
'enabled' => env('FIREFLY_OAUTH2_LOGIN_ENABLED', false),
// 'authorization_endpoint_base_uri' => '/oauth2/authorization',
// 'redirection_endpoint_base_uri' => '/login/oauth2/code',
// 'default_success_url' => '/',
// 'always_use_default_success_url' => false,
// 'failure_url' => '/login?error',
],
// …
'registration' => [
// 'google' => [
// 'client_id' => env('GOOGLE_CLIENT_ID'),
// 'client_secret' => env('GOOGLE_CLIENT_SECRET'),
// ],
// 'corp' => [
// 'provider' => 'keycloak',
// 'client_id' => 'portal',
// 'client_secret' => env('KEYCLOAK_CLIENT_SECRET'),
// 'client_authentication_method' => 'client_secret_basic', // client_secret_basic | client_secret_post | none
// 'authorization_grant_type' => 'authorization_code', // authorization_code | client_credentials
// 'redirect_uri' => '{baseUrl}/login/oauth2/code/{registrationId}',
// 'scope' => ['openid', 'profile', 'email'],
// 'client_name' => 'Corporate SSO',
// 'pkce' => true,
// ],
],
'provider' => [
// 'keycloak' => [
// 'issuer_uri' => 'https://sso.example.com/realms/corp',
// 'authorization_uri' => null,
// 'token_uri' => null,
// 'jwk_set_uri' => null,
// 'user_info_uri' => null,
// 'user_name_attribute' => 'sub',
// 'end_session_uri' => null,
// ],
],
],Two base URIs are all the routing there is: /oauth2/authorization/{registrationId} starts a sign-in and
/login/oauth2/code/{registrationId} receives the code, both configurable, both served by ordered filters
rather than routes you write. A registration on a preset (google, github, okta, keycloak, or entra,
which maps to Microsoft) inherits that provider's endpoints, scopes and display name; any other provider is
discovered from its issuer_uri. PKCE is on by default, the state and nonce are single-use, the id token
is validated against the issuer's JWKS, and what your action receives is an OidcUser — claims, never a
token, because the tokens stay in the session encrypted with the application key. For calling a downstream
API rather than signing a person in, Http::oauth2Client('{id}') hands back a Laravel HTTP client that
carries — and refreshes — the access token for you. Highlights: the presets and discovery, PKCE,
id-token validation, RP-initiated logout, client credentials — see
OAuth2 Client, and
OAuth2 Authorization Server for pointing the same protocol the
other way.
#[Component]
final class PingHealthIndicator implements HealthIndicator
{
public function health(): Health
{
return Health::up();
}
}Any #[Component] implementing HealthIndicator is discovered by a bean-scan registrar and aggregated by
GET /actuator/health — a thrown health() degrades to DOWN, never an unhandled 500. Sensitive endpoints
(/actuator/env, /beans, /conditions, ...) return 404 until explicitly exposed via
firefly.management.endpoints.web.exposure.include, and /actuator/metrics + /actuator/prometheus light up
the moment firefly/observability is installed. Highlights: liveness/readiness groups, Db/DiskSpace
built-in indicators, firefly:health/firefly:metrics actuator-over-CLI — see
Actuator and Observability.
TracingFilter (-110) is a #[Component] web filter, the outermost ordered filter in the chain.
Nothing in an application asks for it, and nothing in an application has to:
$span = $this->tracer->startSpan($request->getMethod(), SpanKind::Server, [
'http.request.method' => $request->getMethod(),
'url.path' => $request->getPathInfo(),
'url.scheme' => $request->getScheme(),
'server.address' => $request->getHost(),
'firefly.correlation_id' => (string) $request->headers->get(CorrelationIdFilter::HEADER, ''),
], $this->propagator->extract($request->headers->all()));The last attribute is the correlation id CorrelationIdFilter minted or accepted a moment earlier, so a span
and a problem+json body can always be tied to each other. The fourth argument is the whole trick:
W3CTraceContextPropagator::extract() reads the inbound traceparent and hands back the remote parent, so a
request that arrives with a trace continues it and one that arrives without starts a new root. The span is
renamed to GET /orders/{id} once the router has matched, its ids are published onto Laravel's Context and
Request::$attributes as firefly.trace_id and firefly.span_id, and from there the same trace crosses
five boundaries — inbound HTTP, the CQRS buses, an in-memory or queued EDA envelope on the way out, every
delivery on the way in (a broker's included, through SubscriberRegistrySink), and every outbound Http
call, which injects the header again. It is free until you opt in: the shipped default is NoOpTracer, whose
spans record nothing and whose context is invalid, and the OpenTelemetry adapter binds itself only when the
SDK is installed and firefly.observability.tracing.enabled is on. Highlights: the Tracer/Span port, the propagation
table boundary by boundary, and firefly.logging.structured.format for putting the same ids on every log
line — see Tracing and Logging.
firefly/admin — which arrives with the runtime family — mounts a server-rendered dashboard at /firefly
behind firefly.admin.enabled (default: app.debug): health, metrics, HTTP traffic, beans, a drawn
bean graph, conditions, routes, scheduled tasks, environment, config
properties, caches, loggers, and a datasource page carrying
your connections, what PDO does about holding them open, and the compiled #[Transactional] contract. It
reads those endpoints in-process rather than over HTTP, so it renders pages the JSON surface deliberately
keeps unexposed — which makes its own URL the entire security boundary. firefly.admin.enabled therefore
defaults to app.debug, and an application that enables it with debug off must put the route behind its own
auth middleware. Read the access model first.
The package also ships a Django-admin-style data browser over your own
CrudRepository beans — discovered from the compiled bean catalogue, so nothing is registered by hand — with
filtering, sorting, paging, full CRUD, relations you can walk in both directions, and an
entity map that draws the foreign keys between them. It is gated
separately: firefly.admin.data.enabled defaults to false and deliberately does not follow
app.debug or firefly.admin.enabled, because beans and configuration are facts about the application while
this page shows facts about its users. Writes need firefly.admin.data.writable on top of that, and
creating a record is offered only for an Eloquent-backed resource — for a hand-written aggregate the
invariants live in its constructor, not in a column list, so that case is refused by name.
One more page changes the application rather than describing it: a feature-switch console with three gates, the third of which is not a configuration key — in production every write is refused whatever the other two say.
composer require firefly/openapi mounts GET /openapi.json and a console at /openapi, both generated from
the same RouteManifest the dispatcher dispatches from and the same ConstraintManifest the validator
validates with — no annotation dialect, and nothing that can drift. php artisan firefly:openapi --output=
makes the document a committable build artifact a CI job can diff. The default console is the official
Swagger UI, served from your own origin out of the swagger-api/swagger-ui composer package: full feature
set, no CDN request, no npm step, and it still renders in an air-gapped or strict-CSP deployment. See
OpenAPI.
Requirements: PHP 8.3+ (8.4 recommended), Composer 2, and an existing (or new) Laravel 13 application — LaraFly layers onto Laravel, it is not a standalone runtime. Full details in Installation.
New project — the global installer:
composer global require fireflyframework/larafly
firefly new my-appNew project — bundled installer:
composer global require fireflyframework/larafly
firefly new my-appAdding LaraFly to an existing Laravel app — fireflyframework/larafly is the complete framework library,
developer console included:
composer require fireflyframework/laraflyThe browser dashboard (firefly/admin) and the API-documentation package (firefly/openapi) come with it. The
broker adapters and testing helpers are included too. Configure the transport you need; install its
required PHP extension separately. Applications using the test kit add Testbench with --dev.
composer require firefly/admin # /firefly — the dashboard over the actuator (see its access model first)
composer require firefly/openapi # /openapi.json + /openapi — a spec that cannot drift, and Swagger UIPoint LaraFly at your app's classes and compile it:
'scan' => [
'paths' => [
'App\\' => app_path(),
],
],php artisan firefly:cache
php artisan firefly:servefirefly/cli is the developer-experience console — the Spring Boot Maven/Gradle-plugin analogue.
| Command | What it does |
|---|---|
firefly:cache |
Compiles the app into bootstrap/cache/firefly/ — DI, routes, #[ControllerAdvice] exception handlers, validation constraints, config properties, CQRS handlers, event/message listeners, scheduled tasks, security methods, and #[Transactional] proxy classes — for a zero-reflection boot. |
firefly:clear |
The inverse — deletes bootstrap/cache/firefly/; the app falls back to in-process scanning. |
firefly:about / :routes / :health / :metrics |
Actuator-over-CLI: render the info/env/beans/conditions/mappings endpoints, the route table, aggregated health, or the metrics snapshot in-process, with no HTTP round-trip. |
make:firefly-controller / -service / -component / -handler / -listener / -entity / -repository / -config-properties |
One generator per stereotype — --query on -handler scaffolds a #[QueryHandler], --message on -listener scaffolds a #[MessageListener]. -handler writes two files: the handler and the concrete command/query class its handle() takes. |
firefly:serve / firefly:db |
Thin passthroughs to artisan serve (or octane:start when Octane is installed) and Laravel's own migrate/db:seed/migrate:fresh. |
php artisan make:firefly-handler RegisterWidget
php artisan make:firefly-handler CountWidgets --query
php artisan make:firefly-listener WidgetEventListenerFull flag reference and generated-file contents: CLI.
One published library, fireflyframework/larafly, contains the components under packages/* and the bundled
application skeleton. Its replace entries satisfy component requirements at the framework's version;
component names remain useful module boundaries, with their own test suites. The 32
module guides below group them by concern:
| Group | Module | Package(s) |
|---|---|---|
| Foundation | Error Handling — the product-agnostic error model and RFC-7807 ErrorResponse |
firefly/kernel |
| Foundation | Dependency Injection — stereotypes, scopes, #[Primary]/#[Qualifier]/#[Order] |
firefly/container |
| Foundation | Configuration — profiles, #[ConfigProperties] binding |
firefly/config |
| Foundation | Application Context — the phased boot engine (ApplicationContext port) |
firefly/context |
| Foundation | Auto-Configuration — #[Configuration]/#[Bean] starters, conditions |
firefly/autoconfigure |
| Foundation | Validation — constraint attributes, #[Valid], structured 422s |
firefly/validation |
| Web & API | Web Layer — #[RestController]/#[Controller] routing, RouteManifest, JSON + HTML negotiation |
firefly/web |
| Web & API | Web Filters — the ordered filter chain onto Laravel middleware | firefly/web |
| Web & API | OpenAPI — OpenAPI 3.1 generated from the compiled manifests, firefly:openapi, official Swagger UI from your own origin |
firefly/openapi |
| Resilience & Scheduling | Resilience — retry, circuit breaker, bulkhead, timeout, rate limiter, fallback | firefly/resilience |
| Resilience & Scheduling | Scheduling — #[Scheduled] + distributed locks (cache or Postgres advisory) |
firefly/scheduling, firefly/scheduling-postgres |
| Data & Domain | Domain (DDD) — Entity, ValueObject, AggregateRoot, DomainEvent |
firefly/domain |
| Data & Domain | Data & Repositories — CRUD/paging ports, derived queries, query by example, #[Modifying]/#[Projection]/#[Lock]/#[EntityGraph], slices, exception translation |
firefly/data |
| Data & Domain | Relational Data — EloquentRepository, soft-delete, auditing, optimistic and pessimistic locking |
firefly/data |
| Data & Domain | Transactions — #[Transactional], propagation, isolation, timeouts, #[TransactionalEventListener] |
firefly/data |
| Eventing & Messaging | Event-Driven Architecture — EventPublisher, #[EventListener], retry/DLQ |
firefly/eda |
| Eventing & Messaging | EDA Brokers — RabbitMQ/Postgres/Kafka adapters, the same-tx outbox | firefly/eda-rabbitmq, firefly/eda-postgres, firefly/eda-kafka |
| Eventing & Messaging | Messaging — the lower-level raw-bytes broker layer | firefly/messaging |
| CQRS | CQRS — CommandBus/QueryBus, the domain→integration-event bridge |
firefly/cqrs |
| Security | Security — principal model, HttpSecurity, #[PreAuthorize], JWT/OAuth2 |
firefly/security |
| Security | OAuth2 Client — OpenID Connect login with presets and discovery, PKCE, id-token validation, OidcUser, RP-initiated logout, client credentials, Http::oauth2Client() |
firefly/security-oauth2-client |
| Security | OAuth2 Authorization Server — registered clients, auth code + PKCE with consent, client credentials, refresh rotation, RS256 JWTs, JWKS, discovery, introspection, revocation, userinfo, logout | firefly/security-oauth2-server |
| Operations | Actuator — health, info, env, beans, conditions, mappings | firefly/actuator |
| Operations | Observability — Prometheus-format metrics with histogram buckets, /actuator/prometheus, HTTP exchanges |
firefly/observability |
| Operations | Tracing — Tracer port, OpenTelemetry adapter, W3C traceparent over HTTP/CQRS/EDA |
firefly/observability |
| Operations | Logging — trace-aware log correlation, JSON/ECS/Logstash lines | firefly/observability |
| Operations | Admin Dashboard — the browser dashboard over the actuator, read in-process | firefly/admin |
| Operations | Bean Graph — the dashboard's drawn dependency graph, with cycle reporting | firefly/admin |
| Operations | Data Browser — the dashboard's database browser over CrudRepository beans, off by default |
firefly/admin |
| Testing | Testing — FireflyTestCase, recording doubles, Pest expectations |
firefly/testing |
| Testing | Integration Testing — @group integration, testcontainers |
firefly/testing |
| Tooling | Installer — the global firefly new scaffolding tool |
firefly/installer |
firefly/cli supplies the developer console — see CLI & Project Scaffolding.
All component names resolve through the root library; the top-level skeleton/ template is bundled with it.
Start at the documentation table of contents — it groups every guide by topic. Highlights:
- Getting Started — the full quickstart, adding LaraFly to an existing app.
- Tutorial (Español) — build the wallet service end to end in ~12 guided steps.
- Installation — requirements, the installer, what you get out of the box.
- Architecture — the boot pipeline and kernel layer, in more depth.
- CLI Reference — every
firefly:*command andmake:firefly-*generator. - Laravel ↔ Spring Boot Comparison — concept-by-concept mapping for both audiences.
- Versioning · Contributing · Publishing.
- Every module guide above.
- LaraFly by Example — the complete bilingual book (15 chapters + appendices, PDF + EPUB).
samples/lumen/— the wallet-and-ledger sample this README's showcases are drawn from; run its own test suite withvendor/bin/pest samples/lumen/tests.
Between the tutorial, the book, the module guides, and the
runnable samples/lumen/, you have a complete, concrete tour of the framework — from a first
HTTP endpoint to CQRS, the transactional outbox, method security, and the zero-reflection production cache.
LaraFly's Foundation milestones (M1 through M14 — kernel, container, config, context, autoconfigure,
validation, web, resilience, scheduling, data/domain, eda/messaging, cqrs, security, actuator/observability,
testing, and the CLI/metapackage/skeleton) are complete, followed by real message-broker adapters and the
genuine same-transaction outbox (RabbitMQ, Postgres, Kafka) and the global firefly new installer. What's
still ahead, accurately:
- Broker integration spot-runs in CI. Each broker adapter ships a genuine
@group('integration')round-trip test (RabbitMQ, Postgres, Kafka) gated behind Docker + a live connection string — these run on demand, not in the defaultvendor/bin/pest/CI pass; wiring a scheduled/opt-in CI lane for them is still open. - More actuator endpoints. Fifteen ship today —
/health,/info,/env,/beans,/conditions,/mappings,/loggers,/scheduledtasks,/caches,/configprops,/metrics,/prometheus,/process,/httpexchanges, and/oauth2clientswhen the authorization server is installed — andfirefly.management.server.portmoves the whole surface onto a second listener, withManagementPortGuardmaking the actuator refuse the application port andphp artisan firefly:management:serverunning that second listener in development. Spring Boot's/refresh,/threaddumpand/shutdownare still not implemented. - Deeper security surfaces. OAuth2 client / OpenID Connect login ships as
firefly/security-oauth2-client(Google, GitHub, Okta, Keycloak and Entra presets plus discovery for any provider), and the authorization server asfirefly/security-oauth2-server(registered clients, auth code + PKCE with consent, client credentials, refresh rotation, introspection, revocation, userinfo and RP-initiated logout). IdP adapters beyond those presets (Cognito, internal-db) and MFA are deferred to their own future packages, matching the Java Firefly Framework's module topology. - Read models / projections as a first-class concept. The sample's
LedgerProjectorshows the pattern today via a plain#[EventListener]; a dedicatedfirefly/eventsourcing-style package for event sourcing/snapshots/projections is future work, as it is in PyFly. - Documentation. The end-to-end tutorial (EN + ES), the LaraFly by Example book (15 chapters + appendices, EN + ES, PDF + EPUB), and a docs table of contents all shipped with the documentation-parity milestone. Deeper guides (more recipes, more diagrams) continue to grow from here.
See CHANGELOG.md for the complete, dated history of every shipped milestone.
LaraFly is part of the Firefly Framework ecosystem — one programming model across every runtime:
| Platform | Repository | Status |
|---|---|---|
| Java / Spring Boot | fireflyframework-* (40+ modules) |
Production |
| Python | fireflyframework-pyfly |
Beta (CalVer 26.05+) |
| PHP / Laravel | fireflyframework-php (this repo) |
Active development (CalVer 26.07+) |
| .NET 9 | fireflyframework-dotnet |
Beta (CalVer 26.05+) |
| Rust | fireflyframework-rust |
Active development |
| Frontend (Angular) | flyfront |
Active development |
| GenAI | fireflyframework-genai |
Active development |
| CLI (Go) | fireflyframework-cli |
Active development |
| Requirement | Version |
|---|---|
| PHP | 8.3+ (8.4 recommended) |
| Composer | 2.x |
| Laravel | 13 |
| Git | for cloning and for firefly new's optional git init |
fireflyframework-php publishes its root as one library. Component descriptors under packages/*
preserve module boundaries; skeleton/ is the bundled application template. Before opening a PR, the full gate must pass:
composer install
git config core.hooksPath scripts/hooks # activates the committed pre-push safety guard
composer check # pint --test && phpstan analyse && pest && deptrac analyse
composer mono-validate # component dependency-version consistency
composer test:package # exported and copied consumer installations
.venv-docs/bin/mkdocs build --strict # docs/: link integrity, no orphaned pages
bash scripts/check-no-sensitive-tracked.shPackage boundaries are enforced with Deptrac; packages from an already-shipped milestone are treated as
FROZEN — further edits to their src/ are the exception, not the rule. See
Contributing for the full guide, including the never-commit list
(.superpowers/, .claude, .env, private key material) enforced by the pre-push guard.
Apache-2.0 © Firefly Software Solutions Inc.