Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,67 @@

All notable changes to LaraFly are documented here. This project uses CalVer (`YY.MM.Patch`).

## [26.09.2] - 2026-09-09

A correctness release found by building a real application on `26.09.1`. Five defects, every one of them
reproduced by a failing test first, and every one of them a case where the framework's behaviour contradicted
what its own documentation and error messages said it did. Two are BREAKING in the sense that an application
can observe the change; both changes are the behaviour that was always intended.

### BREAKING

- **`packages/cqrs` — `CommandProcessingException` / `QueryProcessingException` now carry the CAUSE's error
code.** Both wrappers copied a `FireflyException` cause's `httpStatus`, `category` and `severity` — and
then overwrote its `errorCode` with their own `COMMAND_PROCESSING_ERROR` / `QUERY_PROCESSING_ERROR`. Three
quarters of a fault's identity survived the bus and the quarter a client actually branches on did not: a
duplicate came back as `409 COMMAND_PROCESSING_ERROR`, a missing row as `404 COMMAND_PROCESSING_ERROR`, an
authorization denial as `403 COMMAND_PROCESSING_ERROR`. Applications worked around it by catching the
wrapper and re-throwing `getPrevious()` in every controller that dispatched a command. The cause's code is
now copied alongside the other three. **Migration:** if you assert on `COMMAND_PROCESSING_ERROR` for a
fault that has its own code, assert on that code instead — it is the one the cause always declared. A
cause that is not a `FireflyException` still yields the generic code, so genuine internal failures do not
start leaking codes. The Lumen capstone's own security assertion moved from `COMMAND_PROCESSING_ERROR` to
`ACCESS_DENIED` in this release for exactly this reason.

- **`packages/container` — a component's class key is no longer rebound when the application has already
bound it.** The scan runs in `boot()`, after every provider's `register()`, and `ContainerRegistrar` bound
each `#[Component]` class to an autowiring closure unconditionally — so an application that had
deliberately bound a component, which is the normal way to hand one a value the container cannot autowire
(a string from config, a client built from credentials), silently lost that binding. The loss surfaced
nowhere near its cause: boot succeeded, and the first consumer died with `Unresolvable dependency
resolving [Parameter #0 [ <required> string $x ]]`, which reads like a defect in the component. Explicit
bindings now win, which is the precedence rule the bean sweep already applied where a `#[Bean]` name and a
component name collide. **Migration:** none for the common case. If you relied on the scan replacing a
binding you made yourself, remove the binding.

### Fixed

- **`packages/security` + `packages/context` — `php artisan firefly:cache` can now run on an application
that has no manifests yet.** With `firefly.security.method.strict` enabled and no compiled
`security-methods.php`, `SecurityWiringProvider` refused to boot — including for `firefly:cache`, the only
command that writes that file. Its own error message said "Run `php artisan firefly:cache`", and that
command hit the same error: a fresh clone, a cleared cache directory and the first layer of an image build
were all unrecoverable without turning strict mode off by hand. `AppScan::regenerating()` now reports when
`firefly:cache` is the running command, and the strict gate stands down for that boot in favour of the
in-process scan — the same code path that produces the manifest it is about to write.

- **`packages/context` — a stale compiled manifest no longer bricks the command that would replace it.**
Every artefact under `bootstrap/cache/firefly` is treated as absent while `firefly:cache` is running, so a
`component.php` naming a class that has since stopped being autowirable is ignored rather than eagerly
resolved by `EagerSingletonsPass` before the writer is reached. `EagerSingletonsPass` already tolerated an
entry whose class no longer *exists*; this closes the neighbouring case, where the class exists and the
manifest is simply out of date. The recovery for both is now `firefly:cache` rather than
`rm -rf bootstrap/cache/firefly`.

- **`skeleton` — `composer create-project firefly/skeleton` ships its test scaffold again.** The skeleton's
`.gitattributes` carried `/tests export-ignore`, which is right for a library and wrong for a project
template: Composer honours it when exporting the package into the new project, so every scaffolded
application arrived with a `phpunit.xml` pointing at `tests`, an `autoload-dev` mapping `Tests\` to
`tests/`, and no `tests/` directory at all. The first `vendor/bin/phpunit` fatalled with
`Trait "Tests\CreatesApplication" not found` before running a single assertion. Nothing in the template is
export-ignored now, and `tests/SkeletonScaffoldTest.php` asserts that every `autoload-dev` path the
skeleton declares is a directory it actually ships.

## [26.09.1] - 2026-09-03

A correctness release that also grew two surfaces. Several headline features were found not to work at all
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<a href="docs/installation.md#requirements"><img src="https://img.shields.io/badge/php-8.3%2B-blue?logo=php&logoColor=white" alt="PHP 8.3+"></a>
<a href="docs/laravel-comparison.md"><img src="https://img.shields.io/badge/Laravel-13-FF2D20?logo=laravel&logoColor=white" alt="Laravel 13"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-26.09.1-brightgreen" alt="Version: 26.09.1"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-26.09.2-brightgreen" alt="Version: 26.09.2"></a>
<a href="docs/contributing.md#conventions"><img src="https://img.shields.io/badge/PHPStan-max-8A2BE2" alt="PHPStan: max"></a>
<a href="pint.json"><img src="https://img.shields.io/badge/code%20style-Pint-F55247" alt="Code Style: Pint"></a>
</p>
Expand Down Expand Up @@ -65,7 +65,7 @@
[*PyFly by Example*](https://github.com/fireflyframework/fireflyframework-pyfly). It builds **Lumen**, the
wallet-and-ledger service in [`samples/lumen/`](samples/lumen/), from an empty directory into a secured,
event-driven, actuator-observed microservice, chapter by chapter — every listing drawn from that real project
(it boots and its tests pass against this framework version, `26.09.1`).
(it boots and its tests pass against this framework version, `26.09.2`).

The book is **complete and bilingual (English + Spanish)**: a quick start, **fourteen chapters** across four
parts — Foundations (DI, config, HTTP), Modelling & Persisting the Domain (repositories, DDD), Coordinating &
Expand Down Expand Up @@ -332,7 +332,7 @@ and the seam that makes this possible.

Nine showcases below, each an accurate snippet lifted straight from `samples/lumen/` (the wallet-and-ledger
sample) or the framework itself — no invented API. Every attribute and class shown here compiles against the
shipped `26.09.1` release.
shipped `26.09.2` release.

### Attribute DI — `#[Service]`

Expand Down
4 changes: 2 additions & 2 deletions docs/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The single place the current version *is* asserted in code is:
// packages/kernel/src/Version.php
final class Version
{
public const string VERSION = '26.09.1';
public const string VERSION = '26.09.2';
}
```

Expand All @@ -38,7 +38,7 @@ actually cut.
```php
use Firefly\Kernel\Version;

echo Version::VERSION; // "26.09.1"
echo Version::VERSION; // "26.09.2"
```

This is the only version string LaraFly itself exposes; there is no runtime version-detection mechanism
Expand Down
19 changes: 19 additions & 0 deletions packages/container/src/Registrar/ContainerRegistrar.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,25 @@ private function bindClass(ComponentDescriptor $component): void
{
$class = $component->class;

/*
| An explicit binding the application made itself WINS over the scan.
|
| The scan runs during boot(), after every provider's register(), and this method used to rebind
| the class key unconditionally to an autowiring closure — so an application that had deliberately
| bound a #[Component], which is the normal way to hand one a value the container cannot autowire
| (a string read from config, a client built from credentials), silently lost that binding. The
| loss surfaced nowhere near its cause: boot succeeded and the first consumer died with
| `Unresolvable dependency resolving [Parameter #0 [ <required> string $x ]]`, which reads like a
| defect in the component rather than a binding that was discarded.
|
| This is the precedence rule registerBeans() already applies where a #[Bean] name and a component
| name collide: what was declared explicitly wins. Re-registering the same manifest stays
| idempotent, because the second pass now finds the first pass's own binding and leaves it alone.
*/
if ($this->container->bound($class)) {
return;
}

match ($component->scope) {
Scope::Singleton => $this->container->singleton($class, $class),
Scope::Transient => $this->container->bind($class, $class),
Expand Down
47 changes: 47 additions & 0 deletions packages/container/tests/Registrar/ContainerRegistrarTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,50 @@ interfaces: [Greeter::class],

expect($tagged)->toHaveCount(3);
});

/*
| An explicit binding the application made itself WINS over the scan.
|
| The scan runs during boot(), after every provider's register(), and used to rebind each component's
| class key unconditionally to an autowiring closure. So an application that had deliberately bound a
| #[Component] — the normal way to hand a component a value the container cannot autowire, such as a
| string read from config — silently lost that binding. Worse, the loss surfaced nowhere near its cause:
| boot succeeded, and the first thing to ask for the class died with `Unresolvable dependency resolving
| [Parameter #0 [ <required> string $producer ]]`, which reads like a defect in the component rather than
| a binding that was thrown away.
|
| This is the same precedence rule the bean sweep already applies where a #[Bean] name and a component
| name collide: what was declared explicitly wins.
*/
it('does not overwrite a binding the application registered for a component class', function () {
$illuminate = new IlluminateContainer;
$configured = new WithRequiredScalar('dw-control-plane@26.09.2');
$illuminate->instance(WithRequiredScalar::class, $configured);

(new ContainerRegistrar($illuminate))->register(new ComponentManifest([
new ComponentDescriptor(WithRequiredScalar::class, 'component', null, Scope::Singleton, false, 0, null, [], []),
]));

expect($illuminate->make(WithRequiredScalar::class))->toBe($configured)
->and($illuminate->make(WithRequiredScalar::class)->producer)->toBe('dw-control-plane@26.09.2');
});

it('still binds a component class the application has not bound itself', function () {
$illuminate = new IlluminateContainer;

(new ContainerRegistrar($illuminate))->register(new ComponentManifest([
new ComponentDescriptor(SoloBeeper::class, 'component', null, Scope::Singleton, false, 0, null, [], []),
]));

expect($illuminate->bound(SoloBeeper::class))->toBeTrue()
->and($illuminate->make(SoloBeeper::class))->toBeInstanceOf(SoloBeeper::class);
});

/**
* A component the container cannot autowire on its own: `string $producer` has no type to resolve.
* Declared here rather than under tests/Fixtures/ so the shared ComponentScanner never picks it up.
*/
final class WithRequiredScalar
{
public function __construct(public readonly string $producer) {}
}
37 changes: 37 additions & 0 deletions packages/context/src/Scan/AppScan.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
*/
final class AppScan
{
/** The console command that regenerates every artefact below; see self::regenerating(). */
public const string REGENERATE_COMMAND = 'firefly:cache';

public const string COMPONENT = 'component.php';

public const string CONTEXT = 'context.php';
Expand Down Expand Up @@ -95,11 +98,45 @@ public static function paths(Container $app): array
*/
public static function cachedFile(Container $app, string $basename): ?string
{
if (self::regenerating()) {
return null;
}

$path = self::dir($app).'/'.$basename;

return is_file($path) ? $path : null;
}

/**
* True while `php artisan firefly:cache` is the command being run.
*
* The command that WRITES the manifests can only run by booting the very application whose manifests
* it is about to replace, so a boot that trusts what is already on disk makes the command unable to
* repair it: a `component.php` naming a class that no longer autowires kills EagerSingletonsPass
* before the writer is reached, and the only recovery is deleting the cache directory by hand. The
* same applies to strict method security, which refuses to boot without the manifest that this
* command is on its way to produce.
*
* While regenerating, every compiled artefact is therefore treated as absent and each capability
* falls back to its in-process scan — which is exactly the code path that produces the new manifest.
*
* `$_SERVER['argv']` is read rather than `Application::runningConsoleCommand()` because that helper
* inspects `argv[1]` only, so a global option before the command name (`artisan --no-ansi
* firefly:cache`) would defeat it, and because AppScan is handed a bare Container in tests and in
* Lumen, where the helper does not exist at all.
*/
public static function regenerating(): bool
{
if (PHP_SAPI !== 'cli' && PHP_SAPI !== 'phpdbg') {
return false;
}

/** @var mixed $argv */
$argv = $_SERVER['argv'] ?? null;

return is_array($argv) && in_array(self::REGENERATE_COMMAND, $argv, true);
}

public static function dir(Container $app): string
{
$configured = self::config($app)->get('firefly.cache.path');
Expand Down
46 changes: 46 additions & 0 deletions packages/context/tests/Scan/AppScanTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,49 @@ function appScanContainer(array $firefly = []): Container
expect($source)->toContain("'{$basename}'");
}
});

/*
| `firefly:cache` is the command that WRITES the manifests, and it can only run by booting the very
| application whose manifests it is about to replace. Trusting the manifests already on disk during that
| boot makes the command unable to fix the thing it exists to fix: a component.php naming a class that
| can no longer be autowired kills EagerSingletonsPass before the writer runs, and the only recovery is
| deleting the cache directory by hand. While regenerating, the compiled artefacts are therefore ignored
| and every capability falls back to its in-process scan — which is what produces the correct manifest.
*/
it('reports that it is regenerating only while firefly:cache is the running command', function (array $argv, bool $expected) {
$original = $_SERVER['argv'] ?? null;
$_SERVER['argv'] = $argv;

try {
expect(AppScan::regenerating())->toBe($expected);
} finally {
$original === null ? array_key_exists('argv', $_SERVER) && ($_SERVER['argv'] = []) : $_SERVER['argv'] = $original;
}
})->with([
'firefly:cache' => [['artisan', 'firefly:cache'], true],
'with options first' => [['artisan', '--no-ansi', 'firefly:cache'], true],
'another command' => [['artisan', 'migrate'], false],
'a lookalike' => [['artisan', 'firefly:cache-clear'], false],
'no command' => [['artisan'], false],
]);

it('ignores a compiled artefact that exists while firefly:cache is regenerating', function () {
$dir = sys_get_temp_dir().'/firefly-appscan-'.bin2hex(random_bytes(4));
mkdir($dir, 0777, true);
file_put_contents($dir.'/'.AppScan::COMPONENT, '<?php return [];');

$container = appScanContainer(['cache' => ['path' => $dir]]);
$original = $_SERVER['argv'] ?? [];

try {
$_SERVER['argv'] = ['artisan', 'migrate'];
expect(AppScan::cachedFile($container, AppScan::COMPONENT))->toBe($dir.'/'.AppScan::COMPONENT);

$_SERVER['argv'] = ['artisan', 'firefly:cache'];
expect(AppScan::cachedFile($container, AppScan::COMPONENT))->toBeNull();
} finally {
$_SERVER['argv'] = $original;
@unlink($dir.'/'.AppScan::COMPONENT);
@rmdir($dir);
}
});
14 changes: 11 additions & 3 deletions packages/cqrs/src/Exception/CommandProcessingException.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@

/**
* The wrapper DefaultCommandBus re-throws any handler/stage throwable in (unless it is already a
* CommandProcessingException, in which case the bus re-throws as-is). CATEGORY-PRESERVING: when the cause is a
* FireflyException, its httpStatus/category/severity are copied so an expected client/domain fault (validation,
* CommandProcessingException, in which case the bus re-throws as-is). IDENTITY-PRESERVING: when the cause is a
* FireflyException, its errorCode/httpStatus/category/severity are copied so an expected client/domain fault (validation,
* not-found) keeps its own kernel category and is NOT masked into a generic 500; a plain Throwable becomes
* internal/500. Carries the command class + the cause as `previous` (design §2.3 / pyfly command/bus.py:153-164).
*/
Expand All @@ -22,7 +22,15 @@ public function __construct(string $commandClass, Throwable $cause)
{
parent::__construct(
"Processing command [{$commandClass}] failed: {$cause->getMessage()}",
'COMMAND_PROCESSING_ERROR',
/*
| The cause's OWN code, not a generic one. The error code is part of a fault's identity in
| exactly the way its status, category and severity are, and copying three of the four left
| every domain failure indistinguishable on the wire — a duplicate as `409 COMMAND_PROCESSING_ERROR`,
| a missing row as `404 COMMAND_PROCESSING_ERROR` — so callers had nothing to branch on and
| worked around it by rethrowing getPrevious() in every controller. A cause that is not a
| FireflyException has no code of its own and still yields the generic one below.
*/
$cause instanceof FireflyException ? $cause->errorCode() : 'COMMAND_PROCESSING_ERROR',
$cause instanceof FireflyException ? $cause->httpStatus() : 500,
$cause instanceof FireflyException ? $cause->category() : ErrorCategory::Internal,
$cause instanceof FireflyException ? $cause->severity() : ErrorSeverity::Error,
Expand Down
10 changes: 9 additions & 1 deletion packages/cqrs/src/Exception/QueryProcessingException.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,15 @@ public function __construct(string $queryClass, Throwable $cause)
{
parent::__construct(
"Processing query [{$queryClass}] failed: {$cause->getMessage()}",
'QUERY_PROCESSING_ERROR',
/*
| The cause's OWN code, not a generic one. The error code is part of a fault's identity in
| exactly the way its status, category and severity are, and copying three of the four left
| every domain failure indistinguishable on the wire — a duplicate as `409 QUERY_PROCESSING_ERROR`,
| a missing row as `404 QUERY_PROCESSING_ERROR` — so callers had nothing to branch on and
| worked around it by rethrowing getPrevious() in every controller. A cause that is not a
| FireflyException has no code of its own and still yields the generic one below.
*/
$cause instanceof FireflyException ? $cause->errorCode() : 'QUERY_PROCESSING_ERROR',
$cause instanceof FireflyException ? $cause->httpStatus() : 500,
$cause instanceof FireflyException ? $cause->category() : ErrorCategory::Internal,
$cause instanceof FireflyException ? $cause->severity() : ErrorSeverity::Error,
Expand Down
Loading
Loading