Part IV — Observability, Testing & Delivery · Chapter 13
By the end of this chapter you will know exactly what php artisan firefly:cache does — the eleven scanner-and-compiler steps it runs, in order, over your application's own PSR-4 roots, and the bootstrap/cache/firefly/ manifests and #[Transactional] proxy classes it writes — how FireflyCacheServiceProvider loads that cache back in at boot with zero reflection, firefly:clear's exact inverse, the actuator-over-CLI commands (firefly:about/:routes/:health/:metrics) that reuse Chapter 11's endpoints in-process with no HTTP round trip, the eight make:firefly-* generators, and how firefly/installer's firefly new gets you from nothing to a cached, running application in one command.
!!! note "New term: zero-reflection boot"
Every chapter before this one has mentioned, in passing, that a compiled manifest replaces "scan the filesystem with PHP reflection at every boot." A zero-reflection boot is what you get once firefly:cache has actually run: FireflyCacheServiceProvider loads pre-computed manifests straight from PHP files under bootstrap/cache/firefly/, so no attribute, no class, and no method is ever reflected on at request time — the scan happened once, at deploy time, not on every request.
CacheCommand is a thin wrapper — it reads firefly.scan.paths (the same PSR-4 map every earlier chapter's configOverrides() seeded) and hands it to ManifestCacheWriter:
final class CacheCommand extends Command
{
/** @var string */
protected $signature = 'firefly:cache';
public function handle(): int
{
$psr4 = $this->laravel->make('config')->get('firefly.scan.paths', []);
if ($psr4 === []) {
$this->warn('firefly.scan.paths is empty — nothing to compile.');
return self::SUCCESS;
}
$dir = FireflyCachePaths::dir($this->laravel);
$report = (new ManifestCacheWriter)->write($psr4, $dir);
$this->info(sprintf(
'firefly:cache — wrote %d manifest(s) + %d proxy(ies) to %s',
count($report->files),
$report->proxyCount,
$dir,
));
return self::SUCCESS;
}
}ManifestCacheWriter::write() is the real orchestration, and it is worth reading end to end: it is nothing more than every capability package's existing scanner-and-compiler pair, called in sequence, over the same PSR-4 map — no new abstraction, zero edits to any settled package:
final class ManifestCacheWriter
{
public function write(array $psr4, string $dir): CacheReport
{
$report = $this->writeManifests($psr4, $dir);
$count = $this->writeProxies($psr4, $dir);
return new CacheReport([...$report->files, $dir.'/'.FireflyCachePaths::PROXY_MAP], $count);
}
public function writeManifests(array $psr4, string $dir): CacheReport
{
if (! is_dir($dir)) {
mkdir($dir, 0o755, true);
}
$files = [];
// container + context + autoconfigure (folded): one call emits both component + context manifests.
(new AutoConfigManifestCompiler)->write(
$psr4,
$files[] = $dir.'/'.FireflyCachePaths::COMPONENT,
$files[] = $dir.'/'.FireflyCachePaths::CONTEXT,
);
// config
(new ConfigManifestCompiler)->write(
(new ConfigPropertiesScanner)->scan($psr4),
$files[] = $dir.'/'.FireflyCachePaths::CONFIG_PROPERTIES,
);
// web
(new RouteManifestCompiler)->write(
(new RouteScanner)->scan($psr4),
$files[] = $dir.'/'.FireflyCachePaths::ROUTES,
);
// validation — compiles from an explicit class list, not a PSR-4 scan.
(new ConstraintManifestCompiler)->write(
(new ClassEnumerator)->enumerate($psr4),
$files[] = $dir.'/'.FireflyCachePaths::CONSTRAINTS,
);
// cqrs — 3-arg write (handlers + destinations + path).
$handlers = (new HandlerScanner)->scan($psr4);
(new HandlerManifestCompiler)->write(
$handlers['handlers'],
$handlers['destinations'],
$files[] = $dir.'/'.FireflyCachePaths::HANDLERS,
);
// eda
(new EventListenerManifestCompiler)->write(
(new EventListenerScanner)->scan($psr4),
$files[] = $dir.'/'.FireflyCachePaths::EVENT_LISTENERS,
);
// messaging
(new MessageListenerManifestCompiler)->write(
(new MessageListenerScanner)->scan($psr4),
$files[] = $dir.'/'.FireflyCachePaths::MESSAGE_LISTENERS,
);
// scheduling
(new ScheduledManifestCompiler)->write(
(new ScheduledScanner)->scan($psr4),
$files[] = $dir.'/'.FireflyCachePaths::SCHEDULED,
);
// security
(new SecurityMethodManifestCompiler)->write(
(new MethodSecurityScanner)->scan($psr4),
$files[] = $dir.'/'.FireflyCachePaths::SECURITY_METHODS,
);
// data — transactional manifest data (proxy CLASS files added by writeProxies()).
(new TransactionalManifestCompiler)->write(
(new TransactionalScanner)->scan($psr4),
$files[] = $dir.'/'.FireflyCachePaths::TRANSACTIONAL,
);
return new CacheReport($files);
}
}That is ten scan()-then-write() calls — every capability from Chapter 2's component scan through Chapter 10's method-security manifest, each already introduced in an earlier chapter as an in-process scan — plus one more step, writeProxies(), that generates the #[Transactional] proxy classes Chapter 9 taught you about, using the exact same reflection-free ProxyClassGenerator the runtime itself uses when no cache exists yet:
final class ManifestCacheWriter
{
public function writeProxies(array $psr4, string $dir): int
{
$proxyDir = $dir.'/'.FireflyCachePaths::PROXY_DIR;
if (! is_dir($proxyDir)) {
mkdir($proxyDir, 0o755, true);
}
$generator = new ProxyClassGenerator;
$map = [];
foreach ((new TransactionalScanner)->scanProxyMethods($psr4) as $targetClass => $methods) {
$source = $generator->generate($targetClass, $methods);
$proxyClass = $targetClass.'__FireflyTransactionalProxy';
$file = $proxyDir.'/'.str_replace('\\', '_', $targetClass).'.php';
file_put_contents($file, $source);
$map[$proxyClass] = $file;
}
file_put_contents(
$dir.'/'.FireflyCachePaths::PROXY_MAP,
"<?php\n\ndeclare(strict_types=1);\n\n// Generated by firefly/cli. Do not edit.\n\nreturn ".var_export($map, true).";\n",
);
return count($map);
}
}Eleven steps in total land twelve artifacts under bootstrap/cache/firefly/:
bootstrap/cache/firefly/
├── component.php # DI component manifest (container + context + autoconfigure)
├── context.php # application-context manifest
├── config-properties.php # #[ConfigProperties] DTOs
├── routes.php # compiled route table
├── constraints.php # validation constraint manifest
├── handlers.php # #[CommandHandler]/#[QueryHandler] manifest
├── event-listeners.php # #[EventListener] manifest
├── message-listeners.php # #[MessageListener] manifest
├── scheduled.php # #[Scheduled] manifest
├── security-methods.php # #[PreAuthorize]/#[Secured]/#[RolesAllowed] manifest
├── transactional.php # #[Transactional] method manifest
├── proxies.php # FQCN => file classmap for the generated proxies
└── proxies/ # one generated proxy class file per #[Transactional] target
Writing the manifests is only half the story — something has to notice they exist and bind them instead of running the in-process scan. FireflyCacheServiceProvider (auto-discovered) is that something, and its register() runs before any bean resolution at all:
final class FireflyCacheServiceProvider extends ServiceProvider
{
public function register(): void
{
$dir = FireflyCachePaths::dir($this->app);
if (is_file($path = $dir.'/'.FireflyCachePaths::ROUTES)) {
$this->app->instance(RouteManifest::class, RouteManifest::load($path));
}
if (is_file($path = $dir.'/'.FireflyCachePaths::HANDLERS)) {
$this->app->instance(HandlerManifest::class, HandlerManifest::load($path));
}
// ... one such block per manifest type ...
// Proxies: register a classmap autoloader so TransactionalBeanPostProcessor's class_exists($proxyClass)
// is satisfied BEFORE it throws. register() runs at provider-register, before any boot-pass bean resolution.
$proxyMap = $dir.'/'.FireflyCachePaths::PROXY_MAP;
if (is_file($proxyMap)) {
$map = require $proxyMap;
spl_autoload_register(static function (string $class) use ($map): void {
if (isset($map[$class]) && is_file($map[$class])) {
require $map[$class];
}
});
}
}
}Each $app->instance(...) call is unconditional, and every capability's own default is if (! bound(X)) singleton(X, empty) — so the loaded manifest always wins, regardless of which provider Laravel happened to register first. The spl_autoload_register call at the end is what actually makes the cached proxies reachable: it installs a classmap loader keyed by the generated proxy's fully-qualified name, so the very first time TransactionalBeanPostProcessor checks class_exists($proxyClass), the class is already loadable — no reflection, no filesystem scan, just a map lookup.
None of this is mandatory. An application with no cache directory at all still boots — every capability's manifest binding falls back to the in-process scanner exactly as it has in every earlier chapter — just without the zero-reflection guarantee. firefly:cache is a pure performance-and-determinism upgrade you opt into by running it, not a requirement for the framework to function.
The exact inverse: it recursively deletes FireflyCachePaths::dir() — the configured firefly.cache.path, or bootstrap/cache/firefly by default — and nothing else:
final class ClearCommand extends Command
{
/** @var string */
protected $signature = 'firefly:clear';
public function handle(): int
{
$dir = FireflyCachePaths::dir($this->laravel);
if (! is_dir($dir)) {
$this->info('firefly:clear — nothing to remove.');
return self::SUCCESS;
}
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($it as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($dir);
$this->info('firefly:clear — removed '.$dir);
return self::SUCCESS;
}
}Safe to run at any time: the very next boot simply falls back to the in-process scanner, the same as an application that never ran firefly:cache at all.
Chapter 11 built a management surface reachable over HTTP. These four commands render the same endpoints at the terminal, in-process — no HTTP request is made, and none of them reimplement any actuator logic:
final class AboutCommand extends Command
{
/** @var string */
protected $signature = 'firefly:about';
public function handle(ActuatorCliRenderer $renderer): int
{
$this->line('LaraFly '.Version::VERSION);
foreach (['info', 'env', 'beans', 'conditions', 'mappings', 'scheduledtasks'] as $id) {
$this->newLine();
$this->info('# '.$id);
$renderer->render($this, $id);
}
return self::SUCCESS;
}
}firefly:about resolves the compiled ActuatorRegistry (the same registry ActuatorRouteRegistrar populates at boot), calls each endpoint's handle(), and prints the response — the full Spring Boot --debug/actuator introspection story at a glance, in one command. firefly:routes renders just the mappings endpoint (your compiled route table), firefly:health renders health, and firefly:metrics renders metrics — the last one requires firefly/observability to be installed, and prints a warning and exits successfully rather than erroring if it is not.
Every framework stereotype this book has introduced has a matching Artisan generator — pyfly's generate command family, ported to Laravel's own GeneratorCommand base:
| Command | Generates |
|---|---|
make:firefly-controller |
A #[RestController] with a sample #[GetMapping] action, under app/Http. |
make:firefly-service |
A #[Service] bean. |
make:firefly-component |
A #[Component] bean. |
make:firefly-handler |
A #[CommandHandler] by default, or a #[QueryHandler] with --query. |
make:firefly-listener |
An #[EventListener] method by default, or a #[MessageListener] with --message. |
make:firefly-entity |
A DDD entity extending Firefly\Domain\Entity (there is no #[Entity] attribute). |
make:firefly-repository |
A repository interface extending Firefly\Data\Repository\CrudRepository. |
make:firefly-config-properties |
A #[ConfigProperties]-bound configuration DTO. |
make:firefly-handler is representative of the whole family — a GeneratorCommand that picks which stub to render off a single flag:
final class MakeHandlerCommand extends GeneratorCommand
{
/** @var string */
protected $name = 'make:firefly-handler';
/** @var string */
protected $type = 'Firefly handler';
protected function getStub(): string
{
return __DIR__.'/../../../stubs/'.($this->option('query') ? 'query-handler.stub' : 'command-handler.stub');
}
protected function getOptions(): array
{
return [
['query', null, InputOption::VALUE_NONE, 'Generate a #[QueryHandler] instead of a #[CommandHandler].'],
];
}
}and the command-handler stub it renders — with its {{ namespace }}/{{ class }} placeholders substituted by GeneratorCommand the way php artisan make:firefly-handler RegisterWidget would — is exactly the shape Chapter 7 taught you to write by hand:
namespace App\Handlers;
use Firefly\Cqrs\Attributes\CommandHandler;
#[CommandHandler]
final class RegisterWidget
{
/**
* Replace `object` with the concrete Command message class this handler dispatches for
* (or pass it explicitly via #[CommandHandler(SomeCommand::class)]).
*/
public function handle(object $command): mixed
{
return null;
}
}php artisan make:firefly-handler RegisterWidget
php artisan make:firefly-handler CountWidgets --query
Two commands exist purely for a consistent firefly:* naming convention over commands Laravel already ships — neither reimplements any Laravel behavior:
php artisan firefly:serve {--host=127.0.0.1} {--port=8000}
Delegates to artisan serve, or to octane:start when laravel/octane is installed — probed only via class_exists(), so Octane stays an optional runtime dependency firefly/cli's own composer.json never requires.
php artisan firefly:db {action=migrate}
Delegates to Laravel's own database commands: migrate (default), db:seed (firefly:db seed), or migrate:fresh (firefly:db fresh).
firefly/installer ships the global firefly new command — a small Symfony Console binary, not itself a scaffolder. It shells straight out to composer create-project firefly/skeleton:
#[AsCommand(name: 'new', description: 'Create a new LaraFly application')]
final class NewCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
// ... resolve $name, $directory ...
$create = ['composer', 'create-project', 'firefly/skeleton', $directory, '--no-interaction'];
if ($input->getOption('dev')) {
$create[] = '--stability=dev';
}
if ($runner->run($create) !== 0) {
$io->error('composer create-project failed.');
return self::FAILURE;
}
if (! $input->getOption('no-git')) {
$runner->run(['git', 'init', '-q'], $directory);
$runner->run(['git', 'add', '.'], $directory);
$runner->run(['git', 'commit', '-q', '-m', 'Initial commit'], $directory);
}
$io->success("LaraFly application ready at {$directory}");
$io->writeln(" cd {$name}");
$io->writeln(' php artisan firefly:serve');
return self::SUCCESS;
}
}firefly/skeleton — a genuine, top-level Laravel 13 application template pre-wired with the Firefly family and a sample #[RestController]/#[Service] pair — is what composer create-project actually pulls down. Its own composer.json closes the loop this whole chapter has been building toward: firefly:cache runs automatically, right after install, with no manual step:
"scripts": {
"post-create-project-cmd": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan key:generate --ansi",
"@php artisan firefly:cache"
]
}Every application firefly new scaffolds is therefore, from the very first git commit, already boot-cached — a genuinely zero-reflection application on disk, not merely a framework capable of becoming one. bootstrap/cache/firefly/ itself is .gitignored in the skeleton (the manifests are generated fresh per install, not committed), which is exactly what you would expect of a build artifact rather than source.
!!! laravel "Laravel parity"
composer create-project laravel/laravel gives you a bare Laravel app with nothing pre-wired; firefly new (or composer create-project firefly/skeleton) gives you the same Laravel app, plus the Firefly family pre-required, plus a sample stereotype pair, plus a boot cache already warmed — the equivalent of Spring Initializr's "generate a working starter project," aimed at artisan rather than a web form. make:firefly-* mirrors Laravel's own make:controller/make:model family exactly in spirit — an Artisan GeneratorCommand, a stub, a namespace placeholder — just one stereotype attribute deeper.
| Concept | What it does |
|---|---|
firefly:cache |
Runs every capability's own scanner→compiler pair (11 steps, 12 artifacts) over firefly.scan.paths; writes manifests + #[Transactional] proxies to bootstrap/cache/firefly/ |
FireflyCacheServiceProvider |
Binds each cached manifest unconditionally (wins over every empty default) + installs the proxy classmap autoloader — before any bean resolution runs |
firefly:clear |
Recursively deletes only the cache dir; the next boot falls back to in-process scanning |
firefly:about / :routes / :health / :metrics |
Renders Chapter 11's actuator endpoints at the terminal, in-process, no HTTP round trip |
make:firefly-* |
Eight generators, one per stereotype — GeneratorCommand + a stub, exactly like Laravel's own make:* family |
firefly:serve / firefly:db |
Thin passthroughs to artisan serve/octane:start and Laravel's own database commands |
firefly new (firefly/installer) |
Shells out to composer create-project firefly/skeleton; the skeleton runs firefly:cache automatically post-install |
- Run
firefly:cacheagainst Lumen and inspect the output. From the framework's monorepo, pointfirefly.scan.pathsatsamples/lumen/srcand run the command by hand; openbootstrap/cache/firefly/handlers.phpand confirm it listsWithdrawHandlerand its siblings from Chapter 6 and 7, in a form you can read without any reflection at all. - Break the cache on purpose. Run
firefly:cache, then rename a#[CommandHandler]class without re-running it. Confirm the cachedhandlers.phpstill points at the old name — and that onlyfirefly:cacheagain (notfirefly:clear) fixes it — proving the cache is a snapshot, not a live view. - Scaffold a whole vertical with the generators. Use
make:firefly-entity,make:firefly-repository,make:firefly-handler, andmake:firefly-controllerin sequence to build a tiny new feature from scratch, then runfirefly:cacheonce at the end and confirm every piece appears in the compiled manifests together.