Spring Boot's cohesion, native to Laravel 13.
@@ -40,7 +40,7 @@
Table of contents
-- [๐ The Book โ *LaraFly by Example*](#-the-book--larafly-by-example)
+- [๐ The Book โ *larafly by example*](#-the-book--larafly-by-example)
- [Why LaraFly?](#why-larafly)
- [Quickstart](#quickstart)
- [Philosophy](#philosophy)
@@ -60,10 +60,10 @@
---
-## ๐ The Book โ *LaraFly by Example*
+## ๐ The Book โ *larafly by example*
-**LaraFly by Example** is the official, project-driven book for the framework โ a PHP sibling to
-[*PyFly by Example*](https://github.com/fireflyframework/fireflyframework-pyfly). It builds **Lumen**, the
+**larafly by example** is the official, project-driven book for the framework โ a PHP sibling to
+[*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
(its boot and test suite are verified in CI against the same framework source).
@@ -1122,7 +1122,7 @@ Start at the **[documentation table of contents](docs/README.md)** โ it groups
- [Laravel โ Spring Boot Comparison](docs/laravel-comparison.md) โ concept-by-concept mapping for both audiences.
- [Versioning](docs/versioning.md) ยท [Contributing](docs/contributing.md) ยท [Publishing](docs/publishing.md).
- Every [module guide](#modules) above.
-- [*LaraFly by Example*](book/README.md) โ the complete bilingual book (16 chapters + appendices, PDF + EPUB).
+- [*larafly by example*](book/README.md) โ the complete bilingual book (16 chapters + appendices, PDF + EPUB).
- [`samples/lumen/`](samples/lumen/) โ the wallet-and-ledger sample this README's showcases are drawn from;
run its own test suite with `vendor/bin/pest samples/lumen/tests`.
@@ -1161,7 +1161,7 @@ still ahead, accurately:
- **Read models / projections as a first-class concept.** The sample's `LedgerProjector` shows the pattern
today via a plain `#[EventListener]`; a dedicated `firefly/eventsourcing`-style package for event
sourcing/snapshots/projections is future work, as it is in PyFly.
-- **Documentation.** The end-to-end [tutorial](docs/tutorial.md) (EN + ES), the *LaraFly by Example*
+- **Documentation.** The end-to-end [tutorial](docs/tutorial.md) (EN + ES), the *larafly by example*
[book](book/README.md) (16 chapters + appendices, EN + ES, PDF + EPUB), and a
[docs table of contents](docs/README.md) all shipped with the documentation-parity milestone.
Deeper guides (more recipes, more diagrams) continue to grow from here.
diff --git a/book/README.md b/book/README.md
index e83a48a9..94216212 100644
--- a/book/README.md
+++ b/book/README.md
@@ -1,6 +1,6 @@
-# LaraFly by Example โ the book build system
+# larafly by example โ the book build system
-This directory builds *LaraFly by Example*, a bilingual (EN + ES) book teaching
+This directory builds *larafly by example*, a bilingual (EN + ES) book teaching
LaraFly by walking through the `samples/lumen` project chapter by chapter. It
mirrors `fireflyframework-pyfly/book/`'s architecture (WeasyPrint -> PDF, a
hand-rolled EPUB3 assembler, python-markdown with custom directives), renamed
@@ -95,7 +95,7 @@ book/
# note/tip/warning/laravel admonitions, codehilite
epub.py # stdlib-only EPUB3 (OCF) zip assembler
pdf.py # WeasyPrint HTML -> PDF
- gen_cover.py # regenerates art/cover.{svg,png} (firefly/spark motif)
+ gen_cover.py # validates canonical art; --render rasterizes outlined SVGs
verify_code.py # fenced ```php listings: `php -l`, except `source:` ones
run.sh # sets DYLD_FALLBACK_LIBRARY_PATH, execs build.py
requirements.txt # pinned: weasyprint, markdown, pygments, pyyaml, pytest, cairosvg
@@ -105,7 +105,9 @@ book/
print.css # @page rules, running heads, page-break control (PDF only)
pygments.css # syntax-highlighting token colors
art/
- cover.svg, cover.png # generated by gen_cover.py
+ cover{,-es}.{svg,png} # canonical localized front covers
+ back-cover{,-es}.{svg,png} # canonical localized back covers
+ PROVENANCE.md # source and delivered artwork hashes
figures/ # inline-SVG diagrams referenced by ::: figure
openers/ # reserved for future per-chapter opener art (empty)
src/ # EN manuscript (Markdown)
@@ -168,3 +170,24 @@ code they run, and no `php` or `source:` block carries one. The repository's own
prose guard derives both halves of that parity โ the per-chapter sizes, and the
blocks themselves, byte for byte โ and fails the build the moment one edition
stops matching the other.
+
+## Branded front and back covers
+
+The English and Spanish manifests select their own front/back SVG and PNG files
+under `art/`. See [artwork provenance](art/PROVENANCE.md) for the canonical brand
+kit source and delivered asset hashes. The SVG typography is outlined; the build
+needs no author-machine fonts for the covers.
+
+```bash
+book/.venv/bin/python book/build/gen_cover.py
+```
+
+This validates all four SVG/PNG pairs without modifying them. Use an explicit
+`--render` only to rasterize the checked-in SVGs again. The book build fails if a
+configured PNG is missing. Covers fill the PDF trim without running text; EPUB
+editions include accessible front/back documents and localized navigation.
+
+Book-only editions can be published separately under `books-*` tags and must not
+be marked as the latest framework release. Keep the book source commit and asset
+checksums with the edition. A book-only edition does not change framework package
+versions or replace the assets attached to an existing framework version.
diff --git a/book/art/PROVENANCE.md b/book/art/PROVENANCE.md
new file mode 100644
index 00000000..bbae800c
--- /dev/null
+++ b/book/art/PROVENANCE.md
@@ -0,0 +1,80 @@
+# Book artwork provenance
+
+The front and back covers belong to the shared Firefly Framework book collection,
+prepared on 2026-10-08. Canonical delivery: `Framework-Brand-Kit/11-Books/php/`.
+They use the official Firefly Framework identity (A2 treatment) and Manrope
+letterforms converted to vector paths. They contain no live font dependency.
+
+The source SVGs and delivered PNGs are 1500 ร 1850 pixels, matching the book's
+7.5 ร 9.25 inch trim. English uses `cover` and `back-cover`; Spanish uses
+`cover-es` and `back-cover-es`. Both are full pages in PDF and explicit bookends
+in the EPUB reading order. Edition manifests supply accessible alternative text.
+
+`book/build/gen_cover.py` validates this canonical set without changing it.
+An explicit `--render` recreates the PNGs from these SVGs with CairoSVG; it never
+recreates the retired cover design or substitutes machine-local fonts. Raster
+bytes can vary between renderers, so refresh the hashes below if replacing the
+delivered PNGs. Publication checksums identify the actual PDF/EPUB bytes.
+
+## Delivered asset SHA-256
+
+| Asset | SHA-256 |
+|---|---|
+| `cover.svg` | `d8faa683578c0297f57f8d1230daff157cf22d4d8c1cae3d4d33ccd55c800f04` |
+| `cover.png` | `6b9dd580be852e410a7c76ff8dbbc01104390780f1b731477059b8860216eab2` |
+| `cover-es.svg` | `d4a63f98680517a1fe68511b76f1125f72ad2d1e393def3c875c3117e706142e` |
+| `cover-es.png` | `de603dcb41f1ae8ce2881012ffa3e5fed4e6e5871569eff247bca833467402d9` |
+| `back-cover.svg` | `64a6b78c9ec4c23354ab0420579e9087a9b742b105617482cdbad96080ff290b` |
+| `back-cover.png` | `da2e633783f90d47a18152d917271fa44e38ef5ba62a9e8ebd6beac2405b8c16` |
+| `back-cover-es.svg` | `4fb65c27c3c586eba36374c54a6c664b9b780b5e9c6e16d5f9fbaefc9a7ee192` |
+| `back-cover-es.png` | `2c2b9db429bbba63e9f30a6197167574cf022b50b00cca0ae04e2ab06f89c4cb` |
+
+## Documentation and interior diagram identity
+
+The 2026-10-08 identity pass applies shared ink (`#10110f`), warm neutral
+(`#f3f1eb` / `#dedbd2`) and amber (`#ffb34a`) surfaces. Dark amber (`#8a5714`)
+is reserved for readable lines and text on white. Existing blue, green and rust
+status/callout colors and third-party marks retain their semantic distinction.
+
+`diagram-branding.json` inventories the diagrams and records SHA-256 fingerprints
+of their original non-paint structure. These fingerprints cover every technical
+label, path, arrow and layout attribute. Only paint, the old decorative raster
+mark and the added footer are excluded. The original viewBox is recorded too.
+The footer adds 7.8% of the original width below the content; its outlined family
+lockup is embedded with unique SVG IDs and does not cover or scale any original
+shape. Screen readers retain the diagram's original title/description.
+
+The canonical lockup is `docs/assets/larafly-logo-light.svg`,
+from `Framework-Brand-Kit/12-Frameworks/php/`.
+The documentation favicon and small diagram marks use the official kit's
+`02-Icons/favicon.svg`. No legacy snake/insect raster is regenerated.
+
+Run `book/.venv/bin/python book/build/brand_diagrams.py --check` to validate
+identity, geometry, text and mirrors without writing. Omit `--check` to apply
+the idempotent palette/footer pass to a reviewed original source. An intentional
+technical diagram edit requires review and updating its fingerprint; never reset
+fingerprints merely to make a failing check pass.
+
+Book interiors use the same ink, paper and amber theme. Blue informational,
+green tip, rust warning and native Spring/Laravel callout colors remain semantic.
+
+All nine hand-authored diagrams under `docs/assets/diagrams/` are mirrored
+byte-for-byte into `book/art/figures/`. There is no Mermaid/PlantUML export step.
+
+For PDF only, `book/build/pdf.py` rasterizes the small canonical footer lockup
+at 800 pixels wide before WeasyPrint renders it. This preserves the approved
+gradient-y and clipped paths that WeasyPrint otherwise simplifies. Diagram
+labels and technical shapes remain selectable text and vectors. Public SVGs
+and EPUBs keep the outlined vector logo. Gradients whose stops are the same
+color use identical solid paint so WeasyPrint does not drop header backgrounds.
+
+### Outbox layout correction
+
+The `outbox-flow.svg` commit bar previously covered the final pre-commit labels.
+Its bar and atomic-commit caption now sit below both branches and `pg_notify`.
+The transaction frame grows downward by 70 SVG units; the separate-consumer
+section moves down by the same amount into existing bottom whitespace. All
+text, arrow connections, width and overall viewBox remain unchanged. Both
+mirrors match. The manifest retains the previous structure hash and explains
+the intentional layout revision; a geometry guard keeps the commit bar clear
+of labels and the consumer frame inside the original canvas.
diff --git a/book/art/back-cover-es.png b/book/art/back-cover-es.png
new file mode 100644
index 00000000..cc50fe60
Binary files /dev/null and b/book/art/back-cover-es.png differ
diff --git a/book/art/back-cover-es.svg b/book/art/back-cover-es.svg
new file mode 100644
index 00000000..5efc0ca9
--- /dev/null
+++ b/book/art/back-cover-es.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/book/art/back-cover.png b/book/art/back-cover.png
new file mode 100644
index 00000000..1fad75fd
Binary files /dev/null and b/book/art/back-cover.png differ
diff --git a/book/art/back-cover.svg b/book/art/back-cover.svg
new file mode 100644
index 00000000..fbba2ece
--- /dev/null
+++ b/book/art/back-cover.svg
@@ -0,0 +1 @@
+larafly - Back coverFramework by Firefly
\ No newline at end of file
diff --git a/book/art/cover-es.png b/book/art/cover-es.png
new file mode 100644
index 00000000..a5b9c028
Binary files /dev/null and b/book/art/cover-es.png differ
diff --git a/book/art/cover-es.svg b/book/art/cover-es.svg
new file mode 100644
index 00000000..dfd19cc1
--- /dev/null
+++ b/book/art/cover-es.svg
@@ -0,0 +1 @@
+larafly by exampleFramework by Firefly
\ No newline at end of file
diff --git a/book/art/cover.png b/book/art/cover.png
index 14d2492a..915a07b0 100644
Binary files a/book/art/cover.png and b/book/art/cover.png differ
diff --git a/book/art/cover.svg b/book/art/cover.svg
index 4c87a0f7..601e9571 100644
--- a/book/art/cover.svg
+++ b/book/art/cover.svg
@@ -1,114 +1 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-FIREFLY SOFTWARE SOLUTIONS INC.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-LaraFly
-by Example
-Hexagonal PHP Microservices
-on Laravel 13 with the Firefly Framework
-Copyright (c) 2026 Firefly Software Solutions Inc.
-
\ No newline at end of file
+larafly by exampleFramework by Firefly
\ No newline at end of file
diff --git a/book/art/diagram-branding.json b/book/art/diagram-branding.json
new file mode 100644
index 00000000..8776c95b
--- /dev/null
+++ b/book/art/diagram-branding.json
@@ -0,0 +1,87 @@
+{
+ "version": "2026-10-08",
+ "logo": "docs/assets/larafly-logo-light.svg",
+ "mark": "docs/assets/larafly-favicon.svg",
+ "palette": {
+ "#4b5563": "#62645b",
+ "#1f2937": "#10110f",
+ "#374151": "#46483f",
+ "#6b7280": "#62645b",
+ "#9ca3af": "#a6a295",
+ "#d1d5db": "#dedbd2",
+ "#f3f4f6": "#f3f1eb",
+ "#f8fafc": "#faf9f6",
+ "#eef2ff": "#fff0d8",
+ "#ecfdf5": "#edf1e8"
+ },
+ "diagrams": {
+ "docs/assets/diagrams/boot-pipeline.svg": {
+ "structure": "77a2180ee41aa084e582f8cbc1bc8ddbc18024c55eb9e37ac5fb6e1aea06919b",
+ "originalViewBox": "0 0 1120 1000",
+ "mirrors": [
+ "book/art/figures/boot-pipeline.svg"
+ ]
+ },
+ "docs/assets/diagrams/cqrs-eda-bridge.svg": {
+ "structure": "679ecd3e23e10c0dd19c97d6000e0bc927bc68f655645538ae9572d396452079",
+ "originalViewBox": "0 0 1200 820",
+ "mirrors": [
+ "book/art/figures/cqrs-eda-bridge.svg"
+ ]
+ },
+ "docs/assets/diagrams/di-autoconfig.svg": {
+ "structure": "61e54779e19b086c6fb735e45b2d8f84eab4766159d58e18e5b4974b8590cb3e",
+ "originalViewBox": "0 0 1120 970",
+ "mirrors": [
+ "book/art/figures/di-autoconfig.svg"
+ ]
+ },
+ "docs/assets/diagrams/method-interceptor-chain.svg": {
+ "structure": "44bab250ce3d89df314405594787ffaf4fe701f3ed7df342c5df9daabccc8017",
+ "originalViewBox": "0 0 1240 920",
+ "mirrors": [
+ "book/art/figures/method-interceptor-chain.svg"
+ ]
+ },
+ "docs/assets/diagrams/oauth2-authorization-code.svg": {
+ "structure": "68be371c70f2779811347000a211902cb2d4d000dddedfcf214f008ab7343651",
+ "originalViewBox": "0 0 1240 1070",
+ "mirrors": [
+ "book/art/figures/oauth2-authorization-code.svg"
+ ]
+ },
+ "docs/assets/diagrams/outbox-flow.svg": {
+ "structure": "56c906d58240aa387b8a663e6ecdae9ffe067e4a6f0d0c14e97044f9dae07a03",
+ "originalViewBox": "0 0 1200 1080",
+ "mirrors": [
+ "book/art/figures/outbox-flow.svg"
+ ],
+ "previousStructure": "a5ae1cf023bf1d432a6038abcc4348e3d030938859098316469a4792c4af1d77",
+ "layoutRevision": {
+ "date": "2026-10-08",
+ "reason": "Move commit bar below both pre-commit branches and pg_notify label; extend transaction frame 70 units, shift consumer section 70 units into existing bottom whitespace. No text, topology, width or overall viewBox changes."
+ }
+ },
+ "docs/assets/diagrams/request-lifecycle.svg": {
+ "structure": "a8dbb676a409939f39e9fc76b0828304b4d5f400de870f304c755402cf637db0",
+ "originalViewBox": "0 0 1300 900",
+ "mirrors": [
+ "book/art/figures/request-lifecycle.svg"
+ ]
+ },
+ "docs/assets/diagrams/security-filter-chain.svg": {
+ "structure": "7eee7837fdc8566d9718653cd3b97ed10ca41f4eaf9cc61f90ae781edb0d6a72",
+ "originalViewBox": "0 0 1240 900",
+ "mirrors": [
+ "book/art/figures/security-filter-chain.svg"
+ ]
+ },
+ "docs/assets/diagrams/tracing-propagation.svg": {
+ "structure": "8ac1bb2eefe84685714c9ecbcd402f72aed4f6fa20a5b9659c1612f0997ec5ed",
+ "originalViewBox": "0 0 1400 940",
+ "mirrors": [
+ "book/art/figures/tracing-propagation.svg"
+ ]
+ }
+ }
+}
diff --git a/book/art/figures/boot-pipeline.svg b/book/art/figures/boot-pipeline.svg
index 7d6bd77b..1c72fdf3 100644
--- a/book/art/figures/boot-pipeline.svg
+++ b/book/art/figures/boot-pipeline.svg
@@ -1,141 +1,143 @@
-
+Boot pipelineregister() buffers BootPass contributions into PendingBootPasses; the kernel drains and orders them at booting()/booted().
-
+
-
+
- Boot pipeline: register() buffers, the kernel drains + orders
- packages/context/src/Boot/FireflyServiceProvider.php ยท PendingBootPasses.php ยท BootPhase.php
+ Boot pipeline: register() buffers, the kernel drains + orders
+ packages/context/src/Boot/FireflyServiceProvider.php ยท PendingBootPasses.php ยท BootPhase.php
- Laravel discovers package providers ALPHABETICALLY โ order here is irrelevant to boot order
+ Laravel discovers package providers ALPHABETICALLY โ order here is irrelevant to boot order
-
- FireflyServiceProvider subclass
- e.g. firefly/web, firefly/cqrs, โฆ
- register(): passes() โ contributed BootPass[]
-
-
- FireflyServiceProvider subclass
- one per Firefly package
- register(): passes() โ contributed BootPass[]
-
-
- FireflyServiceProvider subclass
- โฆand every other installed package
- register(): passes() โ contributed BootPass[]
+
+ FireflyServiceProvider subclass
+ e.g. firefly/web, firefly/cqrs, โฆ
+ register(): passes() โ contributed BootPass[]
+
+
+ FireflyServiceProvider subclass
+ one per Firefly package
+ register(): passes() โ contributed BootPass[]
+
+
+ FireflyServiceProvider subclass
+ โฆand every other installed package
+ register(): passes() โ contributed BootPass[]
-
-
-
+
+
+
-
- PendingBootPasses (shared, first-bound-wins)
- .add(pass) โ buffered; nothing is resolved yet, so a capability provider
- registering before the kernel is bound never fails
+
+ PendingBootPasses (shared, first-bound-wins)
+ .add(pass) โ buffered; nothing is resolved yet, so a capability provider
+ registering before the kernel is bound never fails
-
+
-
- $app->booting() callback fires ONCE โ before ANY provider's own boot()
+
+ $app->booting() callback fires ONCE โ before ANY provider's own boot()
-
+
-
- drainInto(): resolve FireflyKernel (now bound) โ move every buffered
- pass in; drain() empties the buffer
+
+ drainInto(): resolve FireflyKernel (now bound) โ move every buffered
+ pass in; drain() empties the buffer
-
+
-
- kernel->run(definitionStagePhases) โ DEFINITION stage: pure registry data; 650 is the one write
+
+ kernel->run(definitionStagePhases) โ DEFINITION stage: pure registry data; 650 is the one write
-
-
+
+ 100ConfigAndProfiles
-
+ 200AutoConfigDiscovery
-
+ 300UserConfigurations
-
+ 400ConditionPassOne
-
+ 500AutoConfigurations
-
+ 600ConditionPassTwo
-
+ 650FlushDefinitions
-
+
-
- $app->booted() callback fires ONCE โ after EVERY provider's own boot()
+
+ $app->booted() callback fires ONCE โ after EVERY provider's own boot()
-
+
-
- drainInto() again โ no-op now (buffer already empty); returns the SAME kernel
+
+ drainInto() again โ no-op now (buffer already empty); returns the SAME kernel
-
+
-
- kernel->run(instanceStagePhases) โ INSTANCE stage: operates on resolved instances
+
+ kernel->run(instanceStagePhases) โ INSTANCE stage: operates on resolved instances
-
-
+
+ 700BeanPostProcessors
-
+ 800EventListeners
-
+ 850InfrastructureStart
-
+ 900EagerSingletons
-
+ 1000WiringPasses
-
+ 1200ContextRefreshed
-
+
-
- bootApplicationContext(): kernel->boot() finalizes โ ApplicationContext bound as singleton;
- close() wired to terminating() (PHP-FPM) or WorkerStopping (Octane)
+
+ bootApplicationContext(): kernel->boot() finalizes โ ApplicationContext bound as singleton;
+ close() wired to terminating() (PHP-FPM) or WorkerStopping (Octane)
- FireflyKernel::run() is idempotent PER PHASE โ every other provider's own booting()/booted() callback becomes a no-op after the first.
+ FireflyKernel::run() is idempotent PER PHASE โ every other provider's own booting()/booted() callback becomes a no-op after the first.
+
+
diff --git a/book/art/figures/cqrs-eda-bridge.svg b/book/art/figures/cqrs-eda-bridge.svg
index dd8d50b0..1ff7087e 100644
--- a/book/art/figures/cqrs-eda-bridge.svg
+++ b/book/art/figures/cqrs-eda-bridge.svg
@@ -1,94 +1,96 @@
-
+CQRS / EDA bridgeCommand/query buses and the domain-to-integration-event after-commit bridge.
-
+
-
+
- CQRS / EDA bridge: buses + the domain โ integration-event trigger
- packages/cqrs/src/{Command/DefaultCommandBus.php, Query/DefaultQueryBus.php, Event/DomainEventBridge.php,
- Event/EdaCommandEventPublisher.php, Boot/DomainEventBridgeWiringPass.php} ยท packages/data/src/Domain/DomainEventDispatcher.php
+ CQRS / EDA bridge: buses + the domain โ integration-event trigger
+ packages/cqrs/src/{Command/DefaultCommandBus.php, Query/DefaultQueryBus.php, Event/DomainEventBridge.php,
+ Event/EdaCommandEventPublisher.php, Boot/DomainEventBridgeWiringPass.php} ยท packages/data/src/Domain/DomainEventDispatcher.php
-
- CommandBus::send(command)
- correlate โ validate โ authorize โ
- resolve + invoke #[CommandHandler]
- (often #[Transactional]) โ metrics
+
+ CommandBus::send(command)
+ correlate โ validate โ authorize โ
+ resolve + invoke #[CommandHandler]
+ (often #[Transactional]) โ metrics
-
- QueryBus::ask(query)
- correlate โ validate โ authorize โ
- cache-get (short-circuit) โ resolve +
- invoke #[QueryHandler] โ cache-put โ metrics
- reads commit no DomainEvents โ never reaches the bridge
+
+ QueryBus::ask(query)
+ correlate โ validate โ authorize โ
+ cache-get (short-circuit) โ resolve +
+ invoke #[QueryHandler] โ cache-put โ metrics
+ reads commit no DomainEvents โ never reaches the bridge
-
+
-
- Aggregate commits (firefly/data #[Transactional] proxy) โ committed DomainEvent(s) published
- to the in-process 'events' dispatcher inside DB::afterCommit (ApplicationEventPublisher)
+
+ Aggregate commits (firefly/data #[Transactional] proxy) โ committed DomainEvent(s) published
+ to the in-process 'events' dispatcher inside DB::afterCommit (ApplicationEventPublisher)
-
+
-
- DomainEventBridgeWiringPass (WiringPasses/1000): registers ONE guarded wildcard listener โ
- dispatcher->listen('*', โฆ) โ filters instanceof DomainEvent
- (a typed #[AsEventListener] on the abstract DomainEvent base can never match a concrete subclass)
+
+ DomainEventBridgeWiringPass (WiringPasses/1000): registers ONE guarded wildcard listener โ
+ dispatcher->listen('*', โฆ) โ filters instanceof DomainEvent
+ (a typed #[AsEventListener] on the abstract DomainEvent base can never match a concrete subclass)
-
+
-
- DomainEventBridge::publish($event) โ applies EventFailureStrategy:
- Log (default, swallow + log) or Raise (rethrow as CommandProcessingException)
+
+ DomainEventBridge::publish($event) โ applies EventFailureStrategy:
+ Log (default, swallow + log) or Raise (rethrow as CommandProcessingException)
-
+
-
- CommandEventPublisher โ NoOpEventPublisher (default) or EdaCommandEventPublisher
+
+ CommandEventPublisher โ NoOpEventPublisher (default) or EdaCommandEventPublisher
- no eda EventPublisher bound โ
- stays NoOp, drops silently
+ no eda EventPublisher bound โ
+ stays NoOp, drops silently
-
+
-
- EdaCommandEventPublisher maps DomainEvent โ integration event:
- eventType = short class name ยท payload = get_object_vars()
- destination = explicit arg โ #[PublishDomainEvent(destination:)] โ firefly.cqrs.default_destination ('cqrs.events')
- headers['x-correlation-id'] stamped from CorrelationContext
+
+ EdaCommandEventPublisher maps DomainEvent โ integration event:
+ eventType = short class name ยท payload = get_object_vars()
+ destination = explicit arg โ #[PublishDomainEvent(destination:)] โ firefly.cqrs.default_destination ('cqrs.events')
+ headers['x-correlation-id'] stamped from CorrelationContext
-
+
-
- firefly/eda EventPublisher port: publish(destination, eventType, payload, headers)
+
+ firefly/eda EventPublisher port: publish(destination, eventType, payload, headers)
-
+
-
-
+
+ memory
-
+ queue
-
+ rabbitmq
-
+ postgres(same-tx outbox)
-
+ kafka
- Fires for ANY committed aggregate โ not only CommandBus writes. The bridge listens on the in-process
- dispatcher, not on a specific command's aggregate.
+ Fires for ANY committed aggregate โ not only CommandBus writes. The bridge listens on the in-process
+ dispatcher, not on a specific command's aggregate.
- Non-atomic here: the DB commit and the broker publish are two steps with a gap โ true same-tx atomicity is the postgres outbox adapter (see outbox-flow.svg).
+ Non-atomic here: the DB commit and the broker publish are two steps with a gap โ true same-tx atomicity is the postgres outbox adapter (see outbox-flow.svg).
+
+
diff --git a/book/art/figures/di-autoconfig.svg b/book/art/figures/di-autoconfig.svg
index 831ba34a..9690fe1d 100644
--- a/book/art/figures/di-autoconfig.svg
+++ b/book/art/figures/di-autoconfig.svg
@@ -1,90 +1,92 @@
-
+Dependency injection and auto-configurationPSR-4 scanner to cached manifest to ConditionPassTwo to bean registry to eager singletons.
-
+
-
+
- DI + auto-configuration: scan โ manifest โ condition pass โ bean registry
- packages/container/src/Scanner ยท packages/context/src/{Scanner,Pass} ยท packages/autoconfigure/src
+ DI + auto-configuration: scan โ manifest โ condition pass โ bean registry
+ packages/container/src/Scanner ยท packages/context/src/{Scanner,Pass} ยท packages/autoconfigure/src
-
- PSR-4 scan roots โ config('firefly.scan.paths')
+
+ PSR-4 scan roots โ config('firefly.scan.paths')
-
+
- reflection happens ONCE, here โ never again at boot
+ reflection happens ONCE, here โ never again at boot
-
- ComponentScanner
- discovers #[Component]/#[Configuration]/
- #[Bean]/stereotypes (e.g. #[RestController])
+
+ ComponentScanner
+ discovers #[Component]/#[Configuration]/
+ #[Bean]/stereotypes (e.g. #[RestController])
-
- ContextScanner
- discovers #[PostConstruct]/#[PreDestroy]/
- #[AsEventListener]/#[ConditionalOn*]
+
+ ContextScanner
+ discovers #[PostConstruct]/#[PreDestroy]/
+ #[AsEventListener]/#[ConditionalOn*]
-
-
+
+
-
- ManifestCompiler.write(): var_export() โ require-able PHP file
+
+ ManifestCompiler.write(): var_export() โ require-able PHP file
-
+
-
- Compiled manifest (ComponentManifest / ContextManifest) โ loaded with ZERO reflection at boot
+
+ Compiled manifest (ComponentManifest / ContextManifest) โ loaded with ZERO reflection at boot
-
+
- โ FireflyKernel boot phases โ
+ โ FireflyKernel boot phases โ
-
- AutoConfigDiscoveryPass (200): load each candidate's manifests, ASSEMBLE BeanDefinitions
- tagged DefinitionSource::AutoConfiguration โ writes NOTHING to the registry yet
+
+ AutoConfigDiscoveryPass (200): load each candidate's manifests, ASSEMBLE BeanDefinitions
+ tagged DefinitionSource::AutoConfiguration โ writes NOTHING to the registry yet
-
-
+
+
-
- UserConfigurations (300): user
- #[Configuration]/#[Bean] enter the registry
+
+ UserConfigurations (300): user
+ #[Configuration]/#[Bean] enter the registry
-
- ConditionPassOne (400): evaluate conditions
- over USER definitions โ PARSE_CONFIGURATION
+
+ ConditionPassOne (400): evaluate conditions
+ over USER definitions โ PARSE_CONFIGURATION
-
-
+
+
-
- AutoConfigurations (500): assembled definitions committed into the registry, tagged DefinitionSource::AutoConfiguration
+
+ AutoConfigurations (500): assembled definitions committed into the registry, tagged DefinitionSource::AutoConfiguration
-
+
-
- ConditionPassTwoPass (600) โ REGISTER_BEAN, INCREMENTAL not batch
- 1. pull every AutoConfiguration definition OUT of the registry
- 2. sort the pulled-out candidates by (#[Order], FQCN) โ read from the manifest, never from a resolved instance
- 3. for each, in that order: evaluate #[ConditionalOnMissingBean]/#[ConditionalOnProperty]/โฆ against the registry
- AS IT CURRENTLY STANDS (surviving User defs + already-accepted AutoConfig defs) โ add back only on a match
- a definition never sees its own contribution while its own conditions run โ first-registered wins a #[ConditionalOnMissingBean] tie
+
+ ConditionPassTwoPass (600) โ REGISTER_BEAN, INCREMENTAL not batch
+ 1. pull every AutoConfiguration definition OUT of the registry
+ 2. sort the pulled-out candidates by (#[Order], FQCN) โ read from the manifest, never from a resolved instance
+ 3. for each, in that order: evaluate #[ConditionalOnMissingBean]/#[ConditionalOnProperty]/โฆ against the registry
+ AS IT CURRENTLY STANDS (surviving User defs + already-accepted AutoConfig defs) โ add back only on a match
+ a definition never sees its own contribution while its own conditions run โ first-registered wins a #[ConditionalOnMissingBean] tie
-
+
-
- FlushDefinitions (650): the ONE ContainerRegistrar::register() write โ Bean registry
+
+ FlushDefinitions (650): the ONE ContainerRegistrar::register() write โ Bean registry
-
+
-
- EagerSingletonsPass (900): container->make() every non-#[Lazy] Scope::Singleton bean +
- eager #[Bean] factory, sorted by (order, abstract) โ read from the manifest
+
+ EagerSingletonsPass (900): container->make() every non-#[Lazy] Scope::Singleton bean +
+ eager #[Bean] factory, sorted by (order, abstract) โ read from the manifest
- #[ConditionalOnMissingBean] is how a starter's default backs off the moment you supply your own bean.
+ #[ConditionalOnMissingBean] is how a starter's default backs off the moment you supply your own bean.
+
+
diff --git a/book/art/figures/method-interceptor-chain.svg b/book/art/figures/method-interceptor-chain.svg
index b2d0c16f..7f300914 100644
--- a/book/art/figures/method-interceptor-chain.svg
+++ b/book/art/figures/method-interceptor-chain.svg
@@ -1,102 +1,104 @@
-
+Method interceptor chainHow every AdviceSource contributes rows to one compiled ProxyPlan, how ProxyClassGenerator emits a single proxy class per bean carrying one interceptor property and one baked descriptor factory per advice kind, and how a call then runs security at advice order 100 before transaction at order 1000 through MethodInvocation::proceed().
-
+
-
+
- One proxy per bean: how an advice becomes a link in the chain
- packages/data/src/Proxy/{Advice.php, AdviceSource.php, ProxyPlanner.php, ProxyPlanCompiler.php, ProxyClassGenerator.php, ProxyFactory.php, InterceptorRegistry.php, MethodInvocation.php}
- packages/security/src/Access/Method/{MethodSecurityAdviceSource.php, MethodSecurityInterceptor.php} ยท packages/data/src/{Proxy/TransactionalAdviceSource.php, Transaction/TransactionInterceptor.php}
+ One proxy per bean: how an advice becomes a link in the chain
+ packages/data/src/Proxy/{Advice.php, AdviceSource.php, ProxyPlanner.php, ProxyPlanCompiler.php, ProxyClassGenerator.php, ProxyFactory.php, InterceptorRegistry.php, MethodInvocation.php}
+ packages/security/src/Access/Method/{MethodSecurityAdviceSource.php, MethodSecurityInterceptor.php} ยท packages/data/src/{Proxy/TransactionalAdviceSource.php, Transaction/TransactionInterceptor.php}
-
- Scan and compile โ php artisan firefly:cache (or an in-process scan of firefly.scan.paths when there is no cache)
+
+ Scan and compile โ php artisan firefly:cache (or an in-process scan of firefly.scan.paths when there is no cache)
-
- MethodSecurityAdviceSource
- #[Component] in firefly/security
- advice(): Advice('security', MethodSecurityInterceptor,
- SecurityMethodDescriptor, order 100, inertWhenUnbound: true)
+
+ MethodSecurityAdviceSource
+ #[Component] in firefly/security
+ advice(): Advice('security', MethodSecurityInterceptor,
+ SecurityMethodDescriptor, order 100, inertWhenUnbound: true)
-
- TransactionalAdviceSource
- #[Component] in firefly/data
- advice(): Advice::transactional() โ Advice('tx',
- TransactionInterceptor, TransactionalDescriptor, order 1000)
+
+ TransactionalAdviceSource
+ #[Component] in firefly/data
+ advice(): Advice::transactional() โ Advice('tx',
+ TransactionInterceptor, TransactionalDescriptor, order 1000)
-
- โฆany other AdviceSource
- The port is open: a package contributes a kind of advice
- by shipping one #[Component] that implements
- AdviceSource โ advice(), scan($psr4), render($row).
+
+ โฆany other AdviceSource
+ The port is open: a package contributes a kind of advice
+ by shipping one #[Component] that implements
+ AdviceSource โ advice(), scan($psr4), render($row).
-
-
-
+
+
+
-
- ProxyPlanner merges every source's scan() rows into one ProxyPlan:
- class โ method โ the ordered (advice id, descriptor row) pairs โ sources merge in Advice::order, so each list is outermost-first
+
+ ProxyPlanner merges every source's scan() rows into one ProxyPlan:
+ class โ method โ the ordered (advice id, descriptor row) pairs โ sources merge in Advice::order, so each list is outermost-first
-
+
-
- ProxyPlanCompiler var_exports the plan into proxy-plan.php โ a literal the cached boot requires without reflecting
- ProxyClassGenerator writes ONE class per bean: final class Foo__FireflyTransactionalProxy extends Foo
+
+ ProxyPlanCompiler var_exports the plan into proxy-plan.php โ a literal the cached boot requires without reflecting
+ ProxyClassGenerator writes ONE class per bean: final class Foo__FireflyTransactionalProxy extends Foo
-
+
-
- The generated proxy โ one member pair PER ADVICE KIND the class uses
- private MethodInterceptor $__fireflySecurityInterceptor; ยท private MethodInterceptor $__fireflyTxInterceptor;
- static __fireflySecurityDescriptor('m') / __fireflyTxDescriptor('m') โ the descriptors baked as literals, no lookup, no reflection
+
+ The generated proxy โ one member pair PER ADVICE KIND the class uses
+ private MethodInterceptor $__fireflySecurityInterceptor; ยท private MethodInterceptor $__fireflyTxInterceptor;
+ static __fireflySecurityDescriptor('m') / __fireflyTxDescriptor('m') โ the descriptors baked as literals, no lookup, no reflection
-
- Runtime โ one call, resolved from the container as the proxy, wrapped by ProxyFactory with the interceptors InterceptorRegistry hands it
+
+ Runtime โ one call, resolved from the container as the proxy, wrapped by ProxyFactory with the interceptors InterceptorRegistry hands it
-
- $accounts->transfer($amount)
- the caller holds the proxy, not the bean
+
+ $accounts->transfer($amount)
+ the caller holds the proxy, not the bean
-
+
-
- (new MethodInvocation($this, AccountService::class, 'transfer', [$amount],
- [$this->__fireflySecurityInterceptor, $this->__fireflyTxInterceptor],
- [SecurityMethodDescriptor::class => self::__fireflySecurityDescriptor('transfer'), TransactionalDescriptor::class => โฆ],
- fn (array $__fireflyArgs) => parent::transfer(...$__fireflyArgs)))->proceed()
+
+ (new MethodInvocation($this, AccountService::class, 'transfer', [$amount],
+ [$this->__fireflySecurityInterceptor, $this->__fireflyTxInterceptor],
+ [SecurityMethodDescriptor::class => self::__fireflySecurityDescriptor('transfer'), TransactionalDescriptor::class => โฆ],
+ fn (array $__fireflyArgs) => parent::transfer(...$__fireflyArgs)))->proceed()
-
-
- order 100
- MethodSecurityInterceptor โ reads SecurityMethodDescriptor, evaluates #[PreAuthorize] and #[PreFilter],
- writes a narrowed argument back with setArguments(), then proceed(). A refusal throws HERE โ before any transaction exists.
+
+
+ order 100
+ MethodSecurityInterceptor โ reads SecurityMethodDescriptor, evaluates #[PreAuthorize] and #[PreFilter],
+ writes a narrowed argument back with setArguments(), then proceed(). A refusal throws HERE โ before any transaction exists.
-
+
-
-
- order 1000
- TransactionInterceptor โ reads TransactionalDescriptor: propagation, isolation, readOnly, the enforced timeout,
- the rollback rules. Opens or joins the transaction through TransactionTemplate::execute(), then proceed().
+
+
+ order 1000
+ TransactionInterceptor โ reads TransactionalDescriptor: propagation, isolation, readOnly, the enforced timeout,
+ the rollback rules. Opens or joins the transaction through TransactionTemplate::execute(), then proceed().
-
+
-
- terminal: parent::transfer(...$args) โ the real method, receiving whatever the list holds when it is finally reached
+
+ terminal: parent::transfer(...$args) โ the real method, receiving whatever the list holds when it is finally reached
-
+
-
- Unwinding, outermost LAST: TransactionInterceptor commits or rolls back, then MethodSecurityInterceptor's after()
- applies #[PostAuthorize] and #[PostFilter] to the value the method returned.
+
+ Unwinding, outermost LAST: TransactionInterceptor commits or rolls back, then MethodSecurityInterceptor's after()
+ applies #[PostAuthorize] and #[PostFilter] to the value the method returned.
- Lower advice order runs OUTER โ the same ordering Spring documents for @EnableMethodSecurity against @EnableTransactionManagement. It is what makes
- "a refusal never opens a transaction" a property of the compiled plan rather than a convention anyone has to remember.
- An advice whose interceptor bean is absent is refused at boot (ConfigurationException) unless it declared inertWhenUnbound โ security does, because
- "annotations are inert until firefly.security.enabled is on" is its documented state โ and InterceptorRegistry then hands the proxy a PassThroughInterceptor.
+ Lower advice order runs OUTER โ the same ordering Spring documents for @EnableMethodSecurity against @EnableTransactionManagement. It is what makes
+ "a refusal never opens a transaction" a property of the compiled plan rather than a convention anyone has to remember.
+ An advice whose interceptor bean is absent is refused at boot (ConfigurationException) unless it declared inertWhenUnbound โ security does, because
+ "annotations are inert until firefly.security.enabled is on" is its documented state โ and InterceptorRegistry then hands the proxy a PassThroughInterceptor.
+
+
diff --git a/book/art/figures/oauth2-authorization-code.svg b/book/art/figures/oauth2-authorization-code.svg
index b53d950f..fc7251e1 100644
--- a/book/art/figures/oauth2-authorization-code.svg
+++ b/book/art/figures/oauth2-authorization-code.svg
@@ -1,126 +1,128 @@
-
+OAuth2 authorization code with PKCEThe full authorization-code round trip across three parties โ the browser, a LaraFly relying party running firefly/security-oauth2-client and a LaraFly authorization server running firefly/security-oauth2-server โ naming the real endpoint paths, the entry point's redirect to the login page, the 302 the browser follows out to the authorization endpoint, the single-use state, the nonce, the S256 code challenge and verifier, the authorization server's own login and consent pages, the single-use code, the back-channel token exchange and the JWKS verification of the id token.
-
+
-
+
- Authorization code + PKCE: signing in, end to end
- packages/security-oauth2-client/src/{OAuth2ClientSettings.php, Web/OAuth2AuthorizationRequestRedirectFilter.php, Web/OAuth2LoginAuthenticationFilter.php}
- packages/security-oauth2-server/src/Web/{OAuth2AuthorizationServerFilter.php, AuthorizationEndpoint.php, Consent/ConsentPage.php, Grant/AuthorizationCodeGrant.php, TokenEndpoint.php, JwkSetEndpoint.php}
- packages/security/src/Web/{HttpSecurityFilter.php, EntryPoint/LoginUrlAuthenticationEntryPoint.php, Login/FormLoginFilter.php, Settings/FormLoginSettings.php}
+ Authorization code + PKCE: signing in, end to end
+ packages/security-oauth2-client/src/{OAuth2ClientSettings.php, Web/OAuth2AuthorizationRequestRedirectFilter.php, Web/OAuth2LoginAuthenticationFilter.php}
+ packages/security-oauth2-server/src/Web/{OAuth2AuthorizationServerFilter.php, AuthorizationEndpoint.php, Consent/ConsentPage.php, Grant/AuthorizationCodeGrant.php, TokenEndpoint.php, JwkSetEndpoint.php}
+ packages/security/src/Web/{HttpSecurityFilter.php, EntryPoint/LoginUrlAuthenticationEntryPoint.php, Login/FormLoginFilter.php, Settings/FormLoginSettings.php}
-
-
-
-
- Browser
- Relying party ยท firefly/security-oauth2-client
- Authorization server ยท firefly/security-oauth2-server
-
-
-
- 1
- GET /orders
+
+
+
+
+ Browser
+ Relying party ยท firefly/security-oauth2-client
+ Authorization server ยท firefly/security-oauth2-server
+
+
+
+ 1
+ GET /ordersno session cookie, no Authorization header
-
- 2
- HttpSecurityFilter (-70) denies โ the entry point
+
+ 2
+ HttpSecurityFilter (-70) denies โ the entry pointthe GET is saved in the session; 302 โ /login
-
- 3
- the login page, one button per provider
+
+ 3
+ the login page, one button per provider"Sign in with โฆ" โ GET /oauth2/authorization/{id}
-
- 4
- OAuth2AuthorizationRequestRedirectFilter (-89)
+
+ 4
+ OAuth2AuthorizationRequestRedirectFilter (-89)state, nonce and verifier in the session; 302 to the browser
-
- 5
- the browser follows the 302 out
+
+ 5
+ the browser follows the 302 outGET /oauth2/authorize?โฆ&code_challenge_method=S256
-
- 6
- OAuth2AuthorizationServerFilter (-82) matches the GET
+
+ 6
+ OAuth2AuthorizationServerFilter (-82) matches the GET/oauth2/authorize ยท state ยท nonce ยท code_challenge (S256)
-
- 7
- no session here either โ the server's own /login
+
+ 7
+ no session here either โ the server's own /loginFormLoginFilter (-92) signs them in; the instant is stamped
-
- 8
- ConsentPage โ a checkbox per scope, Allow or Deny
+
+ 8
+ ConsentPage โ a checkbox per scope, Allow or Denyskipped when the client requires none, or consent is stored
-
- 9
- AuthorizationEndpoint mints a SINGLE-USE code
+
+ 9
+ AuthorizationEndpoint mints a SINGLE-USE code302 โ redirect_uri?code=โฆ&state=โฆ โ the registered URI
-
- 10
- the browser follows the redirect back
+
+ 10
+ the browser follows the redirect backGET /login/oauth2/code/{id}?code=โฆ&state=โฆ
-
- 11
- OAuth2LoginAuthenticationFilter (-88)
+
+ 11
+ OAuth2LoginAuthenticationFilter (-88)state spent and compared with hash_equals; redirect_uri exact
-
- 12
- back channel: POST /oauth2/token
+
+ 12
+ back channel: POST /oauth2/tokengrant_type=authorization_code ยท code ยท code_verifier
-
- TokenEndpoint โ AuthorizationCodeGrant
+
+ TokenEndpoint โ AuthorizationCodeGrantverifier hashed and compared, code burned, tokens minted
-
- 13
- id_token validated: iss ยท aud ยท exp ยท nonce
+
+ 13
+ id_token validated: iss ยท aud ยท exp ยท noncethe signature checked against the provider's published keys
-
- GET /oauth2/jwks โ JwkSetEndpoint
+
+ GET /oauth2/jwks โ JwkSetEndpointthe public half of the key firefly:oauth2:keys made, by kid
-
- 14
- OidcUser built; the session id is regenerated
+
+ 14
+ OidcUser built; the session id is regeneratedcontext saved; success events fire; 302 to the saved request
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Steps 1, 3, 5 and 10 are the browser; 2, 4 and 11-14 the relying party (firefly/security-oauth2-client); 6-9 the authorization server (firefly/security-oauth2-server),
- whose /login is firefly/security's and whose consent page is its own. Either half stands alone โ the relying party against Google, Okta or Keycloak through a preset.
- Steps 5 and 10 are the front channel: the browser itself carries state, nonce and the S256 challenge out, and the code back.
- Steps 12 and 13 are back-channel calls the browser never sees: the code alone is worthless without the verifier only the relying party kept โ that is PKCE.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Steps 1, 3, 5 and 10 are the browser; 2, 4 and 11-14 the relying party (firefly/security-oauth2-client); 6-9 the authorization server (firefly/security-oauth2-server),
+ whose /login is firefly/security's and whose consent page is its own. Either half stands alone โ the relying party against Google, Okta or Keycloak through a preset.
+ Steps 5 and 10 are the front channel: the browser itself carries state, nonce and the S256 challenge out, and the code back.
+ Steps 12 and 13 are back-channel calls the browser never sees: the code alone is worthless without the verifier only the relying party kept โ that is PKCE.
+
+
diff --git a/book/art/figures/outbox-flow.svg b/book/art/figures/outbox-flow.svg
index 82887cb0..9bbeee8b 100644
--- a/book/art/figures/outbox-flow.svg
+++ b/book/art/figures/outbox-flow.svg
@@ -1,114 +1,116 @@
-
+The genuine same-transaction outboxAggregate transaction writes the outbox row in-tx; a separate terminal consumer claims PENDING rows with FOR UPDATE SKIP LOCKED.
-
+
-
+
- The genuine same-transaction outbox
- packages/data/src/{Transaction/TransactionTemplate.php,Domain/DomainEventDispatcher.php}
- packages/eda-postgres/src/{Outbox/OutboxPreCommitHook.php,PostgresEventPublisher.php,PostgresEventConsumer.php}
+ The genuine same-transaction outbox
+ packages/data/src/{Transaction/TransactionTemplate.php,Domain/DomainEventDispatcher.php}
+ packages/eda-postgres/src/{Outbox/OutboxPreCommitHook.php,PostgresEventPublisher.php,PostgresEventConsumer.php}
-
- IN THE AGGREGATE'S OWN TRANSACTION โ single connection
+
+ IN THE AGGREGATE'S OWN TRANSACTION โ single connection
-
- #[Transactional] method call โ TransactionInterceptor โ TransactionTemplate::execute()
+
+ #[Transactional] method call โ TransactionInterceptor โ TransactionTemplate::execute()
-
+
-
- connection->beginTransaction()
+
+ connection->beginTransaction()
-
+
-
- $work() runs: aggregate mutates state; RecordsDomainEvents records events onto AggregateTracker
+
+ $work() runs: aggregate mutates state; RecordsDomainEvents records events onto AggregateTracker
-
+
-
- Pre-commit drain (still inside the tx): DomainEventDispatcher::dispatchAfterCommit($connection)
- drains the tracker โ for each event, afterCommit($event, $connection) runs:
+
+ Pre-commit drain (still inside the tx): DomainEventDispatcher::dispatchAfterCommit($connection)
+ drains the tracker โ for each event, afterCommit($event, $connection) runs:
-
+
-
- (a) OutboxPreCommitHook โ the same-tx path
- resolves the AGGREGATE'S OWN connection
- (ConnectionResolverInterface)
- new PostgresEventPublisher(conn, channel,
- emitNotify = driver is pgsql)
- EdaCommandEventPublisher::publish(event) โ INSERT
- firefly_eda_outbox (status=PENDING) on THIS connection/tx
+
+ (a) OutboxPreCommitHook โ the same-tx path
+ resolves the AGGREGATE'S OWN connection
+ (ConnectionResolverInterface)
+ new PostgresEventPublisher(conn, channel,
+ emitNotify = driver is pgsql)
+ EdaCommandEventPublisher::publish(event) โ INSERT
+ firefly_eda_outbox (status=PENDING) on THIS connection/tx
-
- (b) in-process leg
- (separate from the outbox)
- DB::connection($connection)->afterCommit(
- fn() => ApplicationEventPublisher->publish($event)
- ) โ registered now, fires LATER after the real commit
- drives in-process #[AsEventListener]s only โ
- no outbox write on this leg
+
+ (b) in-process leg
+ (separate from the outbox)
+ DB::connection($connection)->afterCommit(
+ fn() => ApplicationEventPublisher->publish($event)
+ ) โ registered now, fires LATER after the real commit
+ drives in-process #[AsEventListener]s only โ
+ no outbox write on this leg
- + SELECT pg_notify(channel, id) โ same tx, pgsql only
+ + SELECT pg_notify(channel, id) โ same tx, pgsql only
-
+
-
- connection->commit()
+
+ connection->commit()
- ATOMIC โ outbox row + aggregate's own writes commit together, or both roll back
+ ATOMIC โ outbox row + aggregate's own writes commit together, or both roll back
-
- separate process โ woken by LISTEN/NOTIFY, or by its own poll fallback
+
+ separate process โ woken by LISTEN/NOTIFY, or by its own poll fallback
-
- TERMINAL CONSUMER โ separate long-running process (firefly:eda:consume โ PostgresEventConsumer)
+
+ TERMINAL CONSUMER โ separate long-running process (firefly:eda:consume โ PostgresEventConsumer)
-
- subscribe(): LISTEN <channel> (pgsql)
+
+ subscribe(): LISTEN <channel> (pgsql)
-
+
-
- poll(timeoutMs): NotificationWaiter waits (best-effort) for the queued NOTIFY โ
- delivered exactly when the row's commit lands
+
+ poll(timeoutMs): NotificationWaiter waits (best-effort) for the queued NOTIFY โ
+ delivered exactly when the row's commit lands
-
+
-
- claim: SELECT * FROM firefly_eda_outbox WHERE status='PENDING'
- ORDER BY id FOR UPDATE SKIP LOCKED (pgsql)
+
+ claim: SELECT * FROM firefly_eda_outbox WHERE status='PENDING'
+ ORDER BY id FOR UPDATE SKIP LOCKED (pgsql)
-
+
-
- ConsumerLoop delivers the ReceivedEnvelope to SubscriberRegistry โ
- drives matching #[EventListener] handler(s)
+
+ ConsumerLoop delivers the ReceivedEnvelope to SubscriberRegistry โ
+ drives matching #[EventListener] handler(s)
-
-
+
+
-
- success โ ack(): UPDATE โฆ SET status='PUBLISHED'
- WHERE id=? AND status='PENDING' (idempotent)
+
+ success โ ack(): UPDATE โฆ SET status='PUBLISHED'
+ WHERE id=? AND status='PENDING' (idempotent)
-
- throw โ nack(): attempts+1 โ back to PENDING (retry)
- or, past max_attempts, status='FAILED'
+
+ throw โ nack(): attempts+1 โ back to PENDING (retry)
+ or, past max_attempts, status='FAILED'
- No EventPublisher::publish() call anywhere in this consumer path โ it reads/updates the outbox
- table directly: no re-INSERT, no self-reference.
+ No EventPublisher::publish() call anywhere in this consumer path โ it reads/updates the outbox
+ table directly: no re-INSERT, no self-reference.
- A single consumer worker gives exactly-once in-process delivery; multiple workers degrade to at-least-once (handlers should be idempotent).
+ A single consumer worker gives exactly-once in-process delivery; multiple workers degrade to at-least-once (handlers should be idempotent).
+
+
diff --git a/book/art/figures/request-lifecycle.svg b/book/art/figures/request-lifecycle.svg
index 59f9f0f2..7d2e651e 100644
--- a/book/art/figures/request-lifecycle.svg
+++ b/book/art/figures/request-lifecycle.svg
@@ -1,105 +1,107 @@
-
+Request lifecycleWebFilter chain, ControllerDispatcher, method security, and RFC-7807 error rendering.
-
+
-
+
- Request lifecycle: filters โ dispatcher โ method security โ controller
- packages/web/src/{Filter,Dispatch,Route} ยท packages/security/src/Web/MethodSecurityControllerGuard.php
+ Request lifecycle: filters โ dispatcher โ method security โ controller
+ packages/web/src/{Filter,Dispatch,Route} ยท packages/security/src/Web/MethodSecurityControllerGuard.php
-
- HTTP Request
+
+ HTTP Request
-
+
-
- Laravel HTTP Kernel middleware stack
+
+ Laravel HTTP Kernel middleware stack
-
+
-
- RequestContextFilter, CorrelationIdFilter โ fixed, prepended by FilterChainRegistrar
+
+ RequestContextFilter, CorrelationIdFilter โ fixed, prepended by FilterChainRegistrar
-
+
-
- Ordered WebFilter beans (#[Order], read from the manifest) โ FilterChainRegistrar
- pushes them onto the global middleware stack at WiringPasses (1000)
+
+ Ordered WebFilter beans (#[Order], read from the manifest) โ FilterChainRegistrar
+ pushes them onto the global middleware stack at WiringPasses (1000)
-
+
-
- Native Laravel route (RouteWiringPass, one per compiled RouteDescriptor) โ
- dispatch closure: ControllerDispatcher::actionFor()
+
+ Native Laravel route (RouteWiringPass, one per compiled RouteDescriptor) โ
+ dispatch closure: ControllerDispatcher::actionFor()
-
+
-
- ControllerDispatcher::actionFor() โ per request, inside try { โฆ } catch (Throwable)
+
+ ControllerDispatcher::actionFor() โ per request, inside try { โฆ } catch (Throwable)
-
- container->make(controllerClass) โ DI-wired #[RestController] singleton
+
+ container->make(controllerClass) โ DI-wired #[RestController] singleton
-
+
-
- ArgumentResolver::resolve(bindings, request, container) โ binds #[PathVariable]/
- #[QueryParam]/#[RequestBody]/#[RequestHeader]/#[UploadedFile]; #[Valid] triggers BeanValidator
+
+ ArgumentResolver::resolve(bindings, request, container) โ binds #[PathVariable]/
+ #[QueryParam]/#[RequestBody]/#[RequestHeader]/#[UploadedFile]; #[Valid] triggers BeanValidator
-
+
-
- ControllerSecurityGuard::check() โ MethodSecurityControllerGuard evaluates the compiled
- #[PreAuthorize] rule (SecurityMethodManifest, no-eval) against SecurityContextHolder
+
+ ControllerSecurityGuard::check() โ MethodSecurityControllerGuard evaluates the compiled
+ #[PreAuthorize] rule (SecurityMethodManifest, no-eval) against SecurityContextHolder
-
+
-
- controller method invoked (#[RestController])
+
+ controller method invoked (#[RestController])
-
+
-
- ResponseFactory โ content negotiation against Accept (JsonMessageConverter)
+
+ ResponseFactory โ content negotiation against Accept (JsonMessageConverter)
-
+
-
- HTTP Response (2xx)
+
+ HTTP Response (2xx)
- exception path
-
+ exception path
+
-
- Throwable during dispatch โ
- ExceptionHandlerRegistry.resolve(e, controllerClass)
+
+ Throwable during dispatch โ
+ ExceptionHandlerRegistry.resolve(e, controllerClass)
-
+
-
- Handler found (controller-local
- #[ExceptionHandler] wins): invoke it,
- render at the exception's status. No
- handler: rethrow, unchanged
+
+ Handler found (controller-local
+ #[ExceptionHandler] wins): invoke it,
+ render at the exception's status. No
+ handler: rethrow, unchanged
-
+
-
- Propagates to Laravel's exception handler:
- RFC-7807 ProblemDetailsRenderer
+
+ Propagates to Laravel's exception handler:
+ RFC-7807 ProblemDetailsRenderer
-
+
-
- application/problem+json response
+
+ application/problem+json response
- A thrown AuthorizationException/AuthenticationException from the security guard flows through the SAME try/catch into this exception path.
+ A thrown AuthorizationException/AuthenticationException from the security guard flows through the SAME try/catch into this exception path.
+
+
diff --git a/book/art/figures/security-filter-chain.svg b/book/art/figures/security-filter-chain.svg
index 4b540b5c..d8d92e75 100644
--- a/book/art/figures/security-filter-chain.svg
+++ b/book/art/figures/security-filter-chain.svg
@@ -1,152 +1,152 @@
-
+Security filter chainThe ordered WebFilter chain on Laravel's global middleware stack โ the two prepended framework filters, tracing, HTTP exchanges and metrics, security headers, session context persistence, logout, the five authentication mechanisms, the OAuth2 client and authorization-server filters, CSRF and the deny-by-default URL rules โ with each filter's real #[Order] value, and the DelegatingAuthenticationEntryPoint that decides between a login redirect, a Basic challenge and a 401.
-
+
-
+
- The security filter chain: every filter, in the order the registrar sorts it
- packages/web/src/Filter/{FilterChainRegistrar.php, RequestContextFilter.php, CorrelationIdFilter.php} ยท packages/observability/src/Web/{TracingFilter.php, HttpExchangeFilter.php, MetricsFilter.php}
- packages/security/src/{Web,Web/Basic,Web/Login,Web/Logout,Web/RememberMe,Web/EntryPoint,Session,OAuth2}/*.php
- packages/security-oauth2-client/src/Web/*.php ยท packages/security-oauth2-server/src/Web/OAuth2AuthorizationServerFilter.php
+ The security filter chain: every filter, in the order the registrar sorts it
+ packages/web/src/Filter/{FilterChainRegistrar.php, RequestContextFilter.php, CorrelationIdFilter.php} ยท packages/observability/src/Web/{TracingFilter.php, HttpExchangeFilter.php, MetricsFilter.php}
+ packages/security/src/{Web,Web/Basic,Web/Login,Web/Logout,Web/RememberMe,Web/EntryPoint,Session,OAuth2}/*.php
+ packages/security-oauth2-client/src/Web/*.php ยท packages/security-oauth2-server/src/Web/OAuth2AuthorizationServerFilter.php
-
- Laravel's global middleware stack ยท FilterChainRegistrar::orderedFilters()
+
+ Laravel's global middleware stack ยท FilterChainRegistrar::orderedFilters()
-
-
-
- prepend
+
+
+
+ prependRequestContextFilter
- seeds firefly.request_id into Laravel's Context
-
-
- prepend
+ seeds firefly.request_id into Laravel's Context
+
+
+ prependCorrelationIdFilter
- reads or mints X-Correlation-Id, echoes it back
-
-
- -110
+ reads or mints X-Correlation-Id, echoes it back
+
+
+ -110TracingFilter
- SERVER span; continues an inbound traceparent
-
-
- -100
+ SERVER span; continues an inbound traceparent
+
+
+ -100HttpExchangeFilter
- records the row /actuator/httpexchanges serves
-
-
- -100
+ records the row /actuator/httpexchanges serves
+
+
+ -100MetricsFilter
- the http_server_requests_seconds timer
-
-
- -95
+ the http_server_requests_seconds timer
+
+
+ -95SecurityHeadersFilter
- HSTS, CSP, frame, nosniff and referrer headers
-
-
- -94
+ HSTS, CSP, frame, nosniff and referrer headers
+
+
+ -94SecurityContextPersistenceFilter
- loads the context from the session; saves it after
-
-
- -93
+ loads the context from the session; saves it after
+
+
+ -93LogoutFilter
- POST /logout: CSRF, then the LogoutHandler
-
-
- -92
+ POST /logout: CSRF, then the LogoutHandler
+
+
+ -92FormLoginFilter
- POST /login: CSRF, then the AuthenticationManager
-
-
- -91
+ POST /login: CSRF, then the AuthenticationManager
+
+
+ -91HttpBasicFilter
- Authorization: Basic โ its OWN entry point on failure
-
-
- -90
+ Authorization: Basic โ its OWN entry point on failure
+
+
+ -90JwtAuthenticationFilter
- Authorization: Bearer, verified by JwtService
-
-
- -89
+ Authorization: Bearer, verified by JwtService
+
+
+ -89OAuth2AuthorizationRequestRedirectFilter
- /oauth2/authorization/{id} โ the provider
-
-
- -88
+ /oauth2/authorization/{id} โ the provider
+
+
+ -88OAuth2LoginAuthenticationFilter
- /login/oauth2/code/{id} โ the code exchange
-
-
- -85
+ /login/oauth2/code/{id} โ the code exchange
+
+
+ -85OAuth2ResourceServerFilter
- a Bearer JWT checked against the issuer's JWKS
-
-
- -83
+ a Bearer JWT checked against the issuer's JWKS
+
+
+ -83RememberMeAuthenticationFilter
- the cookie, once per session, not per request
-
-
- -82
+ the cookie, once per session, not per request
+
+
+ -82OAuth2AuthorizationServerFilter
- /oauth2/authorize, /token, /jwks, /.well-known/*
-
-
- -80
+ /oauth2/authorize, /token, /jwks, /.well-known/*
+
+
+ -80CsrfFilter
- the token for every unsafe method
-
-
- -70
+ the token for every unsafe method
+
+
+ -70HttpSecurityFilter
- deny-by-default URL rules, first match wins
+ deny-by-default URL rules, first match wins
-
+
-
- ControllerDispatcher โ the #[RestController] action
- MethodSecurityControllerGuard enforces a controller's own #[PreAuthorize] here, at dispatch
+
+ ControllerDispatcher โ the #[RestController] action
+ MethodSecurityControllerGuard enforces a controller's own #[PreAuthorize] here, at dispatch
-
+
-
- DelegatingAuthenticationEntryPoint
- firefly.security.http.entry_point ยท default auto
-
- auto
+
+ DelegatingAuthenticationEntryPoint
+ firefly.security.http.entry_point ยท default auto
+
+ autoa BROWSER โ ErrorPageRenderer::prefersHtml(): the requestnames text/html, is neither an XHR nor a wantsJson() call,and its path is not in firefly.web.error-page.json-paths โis sent to the login page when form login or OAuth2 loginis on; else a Basic challenge when HTTP Basic is on; elsethe 401, rendered by firefly/web.
- login
+ loginalways the login page โ refused at boot when neitherform login nor OAuth2 login is enabled.
- challenge
+ challengealways WWW-Authenticate: Basic.
- problem
+ problemalways the exception, rendered by firefly/web.
- prefersHtml(), never handles(): error-page.enabled is a branding
- choice and must never decide whether a person is asked to sign in.
- Reached from HttpSecurityFilter's anonymous denial, and from the
- authorization server's /oauth2/authorize. HttpBasicFilter answers
- with BasicAuthenticationEntryPoint instead โ never this one, because
- a caller that presented Basic has already chosen its mechanism.
+ prefersHtml(), never handles(): error-page.enabled is a branding
+ choice and must never decide whether a person is asked to sign in.
+ Reached from HttpSecurityFilter's anonymous denial, and from the
+ authorization server's /oauth2/authorize. HttpBasicFilter answers
+ with BasicAuthenticationEntryPoint instead โ never this one, because
+ a caller that presented Basic has already chosen its mechanism.
-
- How the order is decided
-
+
+ How the order is decided
+ orderedFilters() sorts every WebFilter bean by itsComponentDescriptor::$order โ the manifest's number, never aresolved instance's โ and breaks ties with strcmp on the class
@@ -155,9 +155,9 @@
PREPENDED: they carry no #[Order] and are never sorted.
-
- What is NOT in this chain
-
+
+ What is NOT in this chain
+ #[PreAuthorize] on a controller action is enforced at dispatch byMethodSecurityControllerGuard, asked by web's ControllerDispatcher.#[PreAuthorize] on any OTHER stereotyped bean is enforced by
@@ -169,7 +169,9 @@
orderedFilters() matches on the COMPONENT class only.
- Every DISCOVERED filter extends OncePerRequestFilter and is an ordinary #[Component] behind at least one #[ConditionalOnProperty]: installing a package
- adds its link, and switching the feature off removes it. Nothing here is a hard-coded list โ only the two prepended framework filters are unconditional.
- Order values are the literal #[Order(...)] arguments on the classes named; they are the contract a WebFilter of your own inserts itself against.
+ Every DISCOVERED filter extends OncePerRequestFilter and is an ordinary #[Component] behind at least one #[ConditionalOnProperty]: installing a package
+ adds its link, and switching the feature off removes it. Nothing here is a hard-coded list โ only the two prepended framework filters are unconditional.
+ Order values are the literal #[Order(...)] arguments on the classes named; they are the contract a WebFilter of your own inserts itself against.
+
+
diff --git a/book/art/figures/tracing-propagation.svg b/book/art/figures/tracing-propagation.svg
index 838f17d0..e2d8f6f6 100644
--- a/book/art/figures/tracing-propagation.svg
+++ b/book/art/figures/tracing-propagation.svg
@@ -1,114 +1,116 @@
-
+Trace context propagationA W3C traceparent entering at the server filter and flowing outward: the SERVER span published to Laravel Context as firefly.trace_id and firefly.span_id, an INTERNAL span on each CQRS message, a PRODUCER span on publish and a CONSUMER span on delivery around an EDA envelope whose headers carry the traceparent โ the in-memory and queue buses, the RabbitMQ, Kafka and Postgres publishers and the outbox relay all reach that seam, while SubscriberRegistrySink traces the delivery side for every broker consumer โ and a CLIENT span on every outbound Http call that injects it again, landing on every log line, on the httpexchanges endpoint and on the dashboard.
-
+
-
-
- One traceparent, five boundaries, three places you can read it
- packages/observability/src/Web/{TracingFilter.php, HttpClientTracingMiddleware.php} ยท packages/observability/src/Tracing/W3CTraceContextPropagator.php
- packages/observability/src/{Cqrs/TracerCqrsTracing.php, Eda/TracerEdaTracing.php, Logging/TraceContextLogProcessor.php, Boot/HttpClientTracingPass.php} ยท packages/eda/src/Tracing/BrokerTracing.php
-
-
- Inbound request header
- traceparent: 00-{32 hex trace-id}-{16 hex span-id}-01
- absent โ a new root trace starts here
-
-
-
-
- TracingFilter โ #[Order(-110)], the outermost ordered filter in the chain
- W3CTraceContextPropagator::extract($request->headers->all()) โ the remote parent ยท startSpan($method, SpanKind::Server, โฆ, $parent)
- renamed to `GET /orders/{id}` once the router has matched ยท http.request.method, url.path, url.scheme, server.address, http.route, http.response.status_code, firefly.correlation_id ยท ERROR on 5xx
-
-
-
-
- Laravel Context and Request::$attributes
- firefly.trace_id ยท firefly.span_id โ the one place everything downstream reads the ids from
-
-
-
-
-
-
- CQRS ยท in-process
- CqrsTracing seam in firefly/cqrs (NoOpCqrsTracing by
- default), filled by TracerCqrsTracing.
- INTERNAL span per command and per query,
- named by the message's short class name
- firefly.cqrs.kind ยท firefly.cqrs.message
- nothing to carry โ it nests under whatever is current
- gated by firefly.observability.tracing.cqrs.enabled, which
- is on by default under the master tracing gate.
-
-
- EDA ยท across the transport
- EdaTracing seam in firefly/eda, filled by TracerEdaTracing. Reached from the in-memory
- and queue buses, from SubscriberRegistrySink (the sink every broker consumer feeds), from
- RabbitMqEventPublisher, KafkaEventPublisher and PostgresEventPublisher (both its writers),
- and from OutboxRelay, whose traceConsume() keeps the relay hop on the row's own trace.
- PRODUCER `publish <destination>` writes traceparent/tracestate into the headers
- CONSUMER `process <destination>` on delivery reads them back and continues that trace
- gated by firefly.observability.tracing.eda.enabled; firefly.eda.tracing.brokers.enabled=false
- downgrades the three broker publishers alone to the no-op, so none of ours is stamped.
-
-
- Outbound HTTP
- HttpClientTracingMiddleware, installed on the Http
- factory by HttpClientTracingPass at boot.
- CLIENT span ยท inject() writes traceparent on the
- PSR-7 request โ url.path, never url.full
- ERROR at โฅ 400 or on a rejection
- works under Http::fake(): global middleware is outermost
- gated by firefly.observability.tracing.http-client.enabled,
- read by the boot pass rather than by a bean condition.
-
-
-
-
-
-
- The next service's own TracingFilter extracts the same traceparent โ and the trace continues, across the process boundary, with no shared code
- The propagator speaks W3C Trace Context over a plain header map, with no SDK involved: version `ff` and all-zero ids are rejected, a future version is read for its first four fields.
-
- Where the ids land
-
-
- Every log line
- TraceContextLogProcessor adds trace_id and span_id
- to each record, beside the correlation and request ids.
- firefly.logging.structured.format: json ยท ecs ยท logstash
- attached per channel at resolution time, so a channel
- built after boot with Log::build() gets neither.
-
-
- /actuator/httpexchanges
- HttpExchangeFilter (-100) runs INSIDE the tracing filter, so the span already exists
- when it records the exchange, and it reads the traceId TracingFilter published off
- Request::$attributes rather than off Context.
- every row carries the trace it belonged to
- a ring of the last 100 by default โ httpexchanges.capacity, clamped to [1, 10000]
-
-
- The admin dashboard
- firefly/admin renders the same exchanges page
- server-side from the actuator registries โ no
- second data path, no JavaScript fetch.
- read its access model before exposing it: the
- dashboard ships no authentication of its own.
-
-
- What is actually recording
- The shipped default is NoOpTracer: non-recording spans, an invalid SpanContext, currentSpan() of null โ every instrumentation site tests
- $span->context()->isValid() before publishing ids. The OpenTelemetry adapter binds itself when the SDK is installed and firefly.observability.tracing.enabled is on.
-
- Both seams follow the CqrsMetrics shape: an interface in the owning package with a no-op default behind #[ConditionalOnMissingBean], and observability's
- #[Order(500)] auto-configuration registering the real implementation first. Neither firefly/cqrs nor firefly/eda depends on firefly/observability.
- A span whose end is deferred past the frame that started it โ the CLIENT span of an Http call ends in the promise's then() โ calls deactivate() as soon as its
- synchronous part is over, which is what keeps every request of an Http::pool() a sibling rather than a chain.
+
+
+ One traceparent, five boundaries, three places you can read it
+ packages/observability/src/Web/{TracingFilter.php, HttpClientTracingMiddleware.php} ยท packages/observability/src/Tracing/W3CTraceContextPropagator.php
+ packages/observability/src/{Cqrs/TracerCqrsTracing.php, Eda/TracerEdaTracing.php, Logging/TraceContextLogProcessor.php, Boot/HttpClientTracingPass.php} ยท packages/eda/src/Tracing/BrokerTracing.php
+
+
+ Inbound request header
+ traceparent: 00-{32 hex trace-id}-{16 hex span-id}-01
+ absent โ a new root trace starts here
+
+
+
+
+ TracingFilter โ #[Order(-110)], the outermost ordered filter in the chain
+ W3CTraceContextPropagator::extract($request->headers->all()) โ the remote parent ยท startSpan($method, SpanKind::Server, โฆ, $parent)
+ renamed to `GET /orders/{id}` once the router has matched ยท http.request.method, url.path, url.scheme, server.address, http.route, http.response.status_code, firefly.correlation_id ยท ERROR on 5xx
+
+
+
+
+ Laravel Context and Request::$attributes
+ firefly.trace_id ยท firefly.span_id โ the one place everything downstream reads the ids from
+
+
+
+
+
+
+ CQRS ยท in-process
+ CqrsTracing seam in firefly/cqrs (NoOpCqrsTracing by
+ default), filled by TracerCqrsTracing.
+ INTERNAL span per command and per query,
+ named by the message's short class name
+ firefly.cqrs.kind ยท firefly.cqrs.message
+ nothing to carry โ it nests under whatever is current
+ gated by firefly.observability.tracing.cqrs.enabled, which
+ is on by default under the master tracing gate.
+
+
+ EDA ยท across the transport
+ EdaTracing seam in firefly/eda, filled by TracerEdaTracing. Reached from the in-memory
+ and queue buses, from SubscriberRegistrySink (the sink every broker consumer feeds), from
+ RabbitMqEventPublisher, KafkaEventPublisher and PostgresEventPublisher (both its writers),
+ and from OutboxRelay, whose traceConsume() keeps the relay hop on the row's own trace.
+ PRODUCER `publish <destination>` writes traceparent/tracestate into the headers
+ CONSUMER `process <destination>` on delivery reads them back and continues that trace
+ gated by firefly.observability.tracing.eda.enabled; firefly.eda.tracing.brokers.enabled=false
+ downgrades the three broker publishers alone to the no-op, so none of ours is stamped.
+
+
+ Outbound HTTP
+ HttpClientTracingMiddleware, installed on the Http
+ factory by HttpClientTracingPass at boot.
+ CLIENT span ยท inject() writes traceparent on the
+ PSR-7 request โ url.path, never url.full
+ ERROR at โฅ 400 or on a rejection
+ works under Http::fake(): global middleware is outermost
+ gated by firefly.observability.tracing.http-client.enabled,
+ read by the boot pass rather than by a bean condition.
+
+
+
+
+
+
+ The next service's own TracingFilter extracts the same traceparent โ and the trace continues, across the process boundary, with no shared code
+ The propagator speaks W3C Trace Context over a plain header map, with no SDK involved: version `ff` and all-zero ids are rejected, a future version is read for its first four fields.
+
+ Where the ids land
+
+
+ Every log line
+ TraceContextLogProcessor adds trace_id and span_id
+ to each record, beside the correlation and request ids.
+ firefly.logging.structured.format: json ยท ecs ยท logstash
+ attached per channel at resolution time, so a channel
+ built after boot with Log::build() gets neither.
+
+
+ /actuator/httpexchanges
+ HttpExchangeFilter (-100) runs INSIDE the tracing filter, so the span already exists
+ when it records the exchange, and it reads the traceId TracingFilter published off
+ Request::$attributes rather than off Context.
+ every row carries the trace it belonged to
+ a ring of the last 100 by default โ httpexchanges.capacity, clamped to [1, 10000]
+
+
+ The admin dashboard
+ firefly/admin renders the same exchanges page
+ server-side from the actuator registries โ no
+ second data path, no JavaScript fetch.
+ read its access model before exposing it: the
+ dashboard ships no authentication of its own.
+
+
+ What is actually recording
+ The shipped default is NoOpTracer: non-recording spans, an invalid SpanContext, currentSpan() of null โ every instrumentation site tests
+ $span->context()->isValid() before publishing ids. The OpenTelemetry adapter binds itself when the SDK is installed and firefly.observability.tracing.enabled is on.
+
+ Both seams follow the CqrsMetrics shape: an interface in the owning package with a no-op default behind #[ConditionalOnMissingBean], and observability's
+ #[Order(500)] auto-configuration registering the real implementation first. Neither firefly/cqrs nor firefly/eda depends on firefly/observability.
+ A span whose end is deferred past the frame that started it โ the CLIENT span of an Http call ends in the promise's then() โ calls deactivate() as soon as its
+ synchronous part is over, which is what keeps every request of an Http::pool() a sibling rather than a chain.
+
+
diff --git a/book/book.es.yaml b/book/book.es.yaml
index 2dde5263..84f54c33 100644
--- a/book/book.es.yaml
+++ b/book/book.es.yaml
@@ -1,17 +1,23 @@
-title: "LaraFly by Example"
-subtitle: "Microservicios PHP Hexagonales en Laravel 13 con el Firefly Framework"
+title: "larafly by example"
+subtitle: "Arquitectura de negocio y polรญticas de aplicaciรณn sobre Laravel"
author: "Firefly Software Solutions Inc."
publisher: "Firefly Software Solutions Inc."
language: "es"
identifier: "urn:uuid:1c24667b-e0ad-42c6-9f28-8cbb241a2314"
rights: "Copyright (c) 2026 Firefly Software Solutions Inc. Bajo licencia Apache-2.0."
-cover_svg: "art/cover.svg"
-cover_png: "art/cover.png"
+cover_svg: "art/cover-es.svg"
+cover_png: "art/cover-es.png"
+cover_alt: "larafly by example. Arquitectura de negocio y polรญticas de aplicaciรณn sobre Laravel."
+back_cover_svg: "art/back-cover-es.svg"
+back_cover_png: "art/back-cover-es.png"
+back_cover_alt: "Contraportada de larafly by example: aplicaciones de negocio con modelos de dominio, servicios, eventos y polรญticas operativas."
trim_width: "7.5in"
trim_height: "9.25in"
manuscript_dir: "src-es"
output_basename: "larafly-by-example-es"
labels:
+ cover: "Portada"
+ back_cover: "Contraportada"
contents: "Contenido"
# NOTA (Tarea B2): la portada + el Inicio rรกpido + la Parte I (Capรญtulos 1-2)
# se incorporaron aquรญ. Tarea B3: Parte I completada (Capรญtulos 3-4) + Parte II
diff --git a/book/book.yaml b/book/book.yaml
index eb85445a..bddb8243 100644
--- a/book/book.yaml
+++ b/book/book.yaml
@@ -1,5 +1,5 @@
-title: "LaraFly by Example"
-subtitle: "Hexagonal PHP Microservices on Laravel 13 with the Firefly Framework"
+title: "larafly by example"
+subtitle: "Business architecture and application policy on Laravel"
author: "Firefly Software Solutions Inc."
publisher: "Firefly Software Solutions Inc."
language: "en"
@@ -7,11 +7,17 @@ identifier: "urn:uuid:7707e7fc-286d-4773-87f8-5525f6703d27"
rights: "Copyright (c) 2026 Firefly Software Solutions Inc. Licensed under Apache-2.0."
cover_svg: "art/cover.svg"
cover_png: "art/cover.png"
+cover_alt: "larafly by example. Business architecture and application policy on Laravel."
+back_cover_svg: "art/back-cover.svg"
+back_cover_png: "art/back-cover.png"
+back_cover_alt: "Back cover of larafly by example: build business applications with domain models, services, events and operational policy."
trim_width: "7.5in"
trim_height: "9.25in"
manuscript_dir: "src"
output_basename: "larafly-by-example"
labels:
+ cover: "Front cover"
+ back_cover: "Back cover"
contents: "Contents"
# NOTE (Task B2): front matter + Quick Start + Part I (Chapters 1-2) landed here.
# Task B3: Part I completed (Chapters 3-4) + Part II opened (Chapters 5-6).
diff --git a/book/build/brand_diagrams.py b/book/build/brand_diagrams.py
new file mode 100644
index 00000000..d47f5aa4
--- /dev/null
+++ b/book/build/brand_diagrams.py
@@ -0,0 +1,140 @@
+"""Apply the approved identity without moving diagram content.
+
+The manifest records the original non-paint structure. Canonical outlined logos
+are embedded, never fetched. Reapplying the operation is byte-idempotent.
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import re
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+MANIFEST = ROOT / "book/art/diagram-branding.json"
+SVG = "{http://www.w3.org/2000/svg}"
+ET.register_namespace("", "http://www.w3.org/2000/svg")
+ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
+PAINT = {"fill", "stroke", "stop-color", "flood-color", "color"}
+
+
+def structure(source: str) -> str:
+ """Fingerprint every original label, path and layout attribute, excluding paint."""
+ root = ET.fromstring(source)
+ def visit(node, top=False):
+ if node.get("data-framework-brand") or node.get("data-brand-decoration") or node.tag == SVG + "image":
+ return None
+ attrs = {key: value for key, value in node.attrib.items() if key not in PAINT}
+ if top:
+ for key in ("height", "viewBox", "data-original-view-box"):
+ attrs.pop(key, None)
+ return [node.tag, sorted(attrs.items()), (node.text or "").strip(),
+ [result for child in node if (result := visit(child)) is not None]]
+ return hashlib.sha256(json.dumps(visit(root, True), ensure_ascii=False).encode()).hexdigest()
+
+
+def embedded_asset(path: Path, prefix: str, **attrs) -> str:
+ root = ET.fromstring(path.read_text())
+ for node in list(root):
+ if node.tag in (SVG + "title", SVG + "desc"):
+ root.remove(node)
+ ids = {node.get("id"): prefix + node.get("id") for node in root.iter() if node.get("id")}
+ for node in root.iter():
+ for key, value in list(node.attrib.items()):
+ if key == "id":
+ node.set(key, ids[value])
+ else:
+ for old, new in ids.items():
+ value = value.replace("#" + old + ")", "#" + new + ")")
+ if value == "#" + old:
+ value = "#" + new
+ node.set(key, value)
+ root.attrib.update({key.replace("_", "-"): str(value) for key, value in attrs.items()})
+ return ET.tostring(root, encoding="unicode")
+
+
+def brand(source: str, relative: str, config: dict) -> str:
+ root = ET.fromstring(source)
+ # WeasyPrint 69 drops fills whose gradient stops collapse to one color.
+ # A solid paint is visually identical and keeps white header labels legible.
+ for gradient in root.iter(SVG + "linearGradient"):
+ stops = list(gradient)
+ colors = {stop.get("stop-color") for stop in stops}
+ if len(colors) == 1 and None not in colors and all(stop.get("stop-opacity", "1") == "1" for stop in stops):
+ color = colors.pop()
+ source = source.replace(f'="url(#{gradient.get("id")})"', f'="{color}"')
+ if root.find(f"{SVG}g[@data-framework-brand='2026-10-08']") is not None:
+ if "" not in source:
+ start = source.index("", 0, source.rfind("")) + len("")
+ source = (source[:start] + "" + source[start:end]
+ + "" + source[end:])
+ return source
+ x, y, width, height = map(float, root.get("viewBox").split())
+ if x or y:
+ raise ValueError("The diagram must use a zero-origin viewBox")
+ prefix = "brand-" + hashlib.sha256(relative.encode()).hexdigest()[:10] + "-"
+ palette = config["palette"]
+ source = re.sub(r"#[0-9a-fA-F]{6}\b", lambda match: palette.get(match[0].lower(), match[0]), source)
+ counter = 0
+ def replace_icon(match):
+ nonlocal counter
+ counter += 1
+ image = ET.fromstring(match[0])
+ return embedded_asset(ROOT / config["mark"], prefix + str(counter) + "-",
+ data_brand_decoration="official-mark", aria_hidden="true",
+ **{key: image.get(key) for key in ("x", "y", "width", "height")})
+ source = re.sub(r"]*?/>|]*?>.*?", replace_icon, source, flags=re.S)
+ footer = round(width * .078, 2)
+ logo_height = round(width * .068, 2)
+ logo = ET.parse(ROOT / config["logo"]).getroot()
+ logo_width = round(logo_height * float(logo.get("width")) / float(logo.get("height")), 2)
+ asset = embedded_asset(ROOT / config["logo"], prefix + "logo-", x=round(width-logo_width-12, 2),
+ y=round(height + (footer-logo_height)/2, 2), width=logo_width, height=logo_height,
+ aria_hidden="true")
+ panel = (f''
+ f''
+ f''
+ f''
+ + '' + asset + '')
+ match = re.search(r"]*>", source)
+ opening = match[0]
+ total_height = round(height + footer, 2)
+ opening = re.sub(r'viewBox="[^"]+"', f'viewBox="0 0 {width:g} {total_height:g}"', opening)
+ if 'height="' in opening:
+ opening = re.sub(r'height="[^"]+"', f'height="{total_height:g}"', opening)
+ opening = opening[:-1] + f' data-original-view-box="0 0 {width:g} {height:g}">'
+ source = source[:match.start()] + opening + source[match.end():]
+ return brand(source.rsplit("", 1)[0] + "\n" + panel + "\n\n", relative, config)
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--check", action="store_true", help="Validate without writing.")
+ args = parser.parse_args(argv)
+ config = json.loads(MANIFEST.read_text())
+ for relative, record in config["diagrams"].items():
+ path = ROOT / relative
+ original = path.read_text()
+ if structure(original) != record["structure"]:
+ raise ValueError(f"Diagram content/layout changed; review its manifest entry: {relative}")
+ result = brand(original, relative, config)
+ if structure(result) != record["structure"]:
+ raise ValueError(f"Branding moved or changed diagram content: {relative}")
+ if args.check and result != original:
+ raise ValueError(f"Diagram needs branding: {relative}")
+ if not args.check and result != original:
+ path.write_text(result)
+ for mirror in record.get("mirrors", []):
+ mirror_path = ROOT / mirror
+ if args.check and mirror_path.read_text() != result:
+ raise ValueError(f"Diagram mirror differs: {mirror}")
+ if not args.check and mirror_path.read_text() != result:
+ mirror_path.write_text(result)
+ print(f"Verified {len(config['diagrams'])} diagram layouts and canonical branding.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/book/build/build.py b/book/build/build.py
index fe96b6ef..11bfe09c 100644
--- a/book/build/build.py
+++ b/book/build/build.py
@@ -1,4 +1,4 @@
-"""Build *LaraFly by Example* into EPUB + PDF from a book manifest.
+"""Build *larafly by example* into EPUB + PDF from a book manifest.
Defaults to ``book.yaml`` (English). Pass ``--config book.es.yaml`` to build the
Spanish edition; each manifest names its own ``manuscript_dir``, ``language``,
@@ -12,7 +12,7 @@
import re
import sys
from pathlib import Path
-from xml.sax.saxutils import escape
+from xml.sax.saxutils import escape, quoteattr
import yaml # PyYAML ships with the framework env; ensure installed in book/.venv
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -128,8 +128,40 @@ def _divider_html(eyebrow: str, ptitle: str) -> str:
f'
{escape(ptitle)}
')
+def _cover_items(cfg: dict) -> dict[str, dict]:
+ """Resolve configured artwork before writing any edition output."""
+ covers = {}
+ labels = cfg.get("labels", {})
+ for cid, key, label in (("cover", "cover", "Front cover"),
+ ("back-cover", "back_cover", "Back cover")):
+ filename = cfg.get(f"{key}_png")
+ if not filename:
+ continue
+ path = BOOK / filename
+ if not path.is_file():
+ raise FileNotFoundError(f"Configured cover artwork is missing: {path}")
+ title = labels.get(key, label)
+ covers[cid] = {"id": cid, "path": path, "filename": filename, "title": title,
+ "alt": cfg.get(f"{key}_alt", f"{title}: {cfg['title']}")}
+ return covers
+
+
+def _add_epub_cover(epub: EpubBuilder, cover: dict) -> None:
+ cid = cover["id"]
+ href = f"art/{cid}.png"
+ epub.add_file(cover["path"], href, f"{cid}-img",
+ properties="cover-image" if cid == "cover" else "")
+ epub.add_doc(Doc(id=cid, title=cover["title"], kind="cover",
+ xhtml_body=f''))
+
+
+def _pdf_cover(cover: dict) -> str:
+ return (f''
+ f'')
+
+
def main(argv: list[str] | None = None) -> int:
- ap = argparse.ArgumentParser(description="Build LaraFly by Example (EPUB + PDF).")
+ ap = argparse.ArgumentParser(description="Build larafly by example (EPUB + PDF).")
ap.add_argument("--config", default="book.yaml",
help="Manifest file under book/ (default: book.yaml).")
ap.add_argument("--out", default=None,
@@ -144,13 +176,13 @@ def main(argv: list[str] | None = None) -> int:
css_text = [(THEME / "book.css").read_text(), (THEME / "tokens.css").read_text(),
(THEME / "pygments.css").read_text()]
items = _items_from_manifest(cfg, man, contents_label=contents_label)
+ covers = _cover_items(cfg)
# ---- EPUB ----
epub = EpubBuilder(title=cfg["title"], author=cfg["author"], language=cfg["language"],
identifier=cfg["identifier"], css=css_text)
- cover_png = BOOK / cfg["cover_png"]
- if cover_png.exists():
- epub.add_file(cover_png, "art/cover.png", "cover-img", properties="cover-image")
+ if "cover" in covers:
+ _add_epub_cover(epub, covers["cover"])
for it in items:
if it["kind"] == "toc":
body = _toc_html(items, href_fmt="{cid}.xhtml", label=contents_label)
@@ -165,13 +197,15 @@ def main(argv: list[str] | None = None) -> int:
epub.add_doc(Doc(id=it["id"], title=it["title"], xhtml_body=body,
in_nav=it.get("in_nav", True), kind=it["kind"],
part=it.get("part"), num=it.get("num")))
+ if "back-cover" in covers:
+ _add_epub_cover(epub, covers["back-cover"])
DIST.mkdir(exist_ok=True)
epub.build(DIST / f"{out_base}.epub")
# ---- PDF (single concatenated document) ----
parts_html: list[str] = []
- if cover_png.exists():
- parts_html.append(f'
')
+ if "cover" in covers:
+ parts_html.append(_pdf_cover(covers["cover"]))
for it in items:
if it["kind"] == "toc":
body = _toc_html(items, href_fmt="#{cid}", label=contents_label)
@@ -182,7 +216,12 @@ def main(argv: list[str] | None = None) -> int:
else: # front | chapter
body = render_markdown(Path(it["path"]).read_text(encoding="utf-8"), BOOK)
parts_html.append(f'{body}')
- full = (""
+ if "back-cover" in covers:
+ parts_html.append(_pdf_cover(covers["back-cover"]))
+ full = (f''
+ f'{escape(cfg["title"])}'
+ f''
+ f''
+ "\n".join(parts_html) + "")
render_pdf(full, base_url=BOOK,
css_paths=[THEME / "tokens.css", THEME / "pygments.css",
diff --git a/book/build/epub.py b/book/build/epub.py
index 97934c0c..8971239c 100644
--- a/book/build/epub.py
+++ b/book/build/epub.py
@@ -15,7 +15,7 @@ class Doc:
title: str
xhtml_body: str
in_nav: bool = True
- kind: str = "chapter" # front | toc | divider | chapter
+ kind: str = "chapter" # front | toc | divider | chapter | cover
part: str | None = None # part label (e.g. "Part I โ Foundations") for grouping
num: int | str | None = None # chapter number, when kind == "chapter"
@@ -51,7 +51,7 @@ def add_file(self, path: Path, href: str, aid: str, properties: str = "") -> Ass
def _xhtml(self, d: Doc) -> str:
links = "\n".join(f''
for i in range(len(self.css)))
- sec_class = {"toc": "toc", "divider": "part-divider"}.get(d.kind, "chapter")
+ sec_class = {"toc": "toc", "divider": "part-divider", "cover": "cover-page"}.get(d.kind, "chapter")
return (f'\n'
f'\n'
@@ -87,11 +87,12 @@ def _nav(self) -> str:
if cur_part is not None:
out.append("")
items = "\n".join(out)
+ label = escape(next((d.title for d in self.docs if d.kind == "toc"), "Contents"))
return ('\n'
''
- 'Contents'
- f''
+ f'xmlns:epub="http://www.idpf.org/2007/ops" lang="{self.language}">'
+ f'{label}'
+ f''
'')
def _opf(self) -> str:
diff --git a/book/build/gen_cover.py b/book/build/gen_cover.py
index 3ea69830..70b497c9 100644
--- a/book/build/gen_cover.py
+++ b/book/build/gen_cover.py
@@ -1,148 +1,54 @@
-"""Generate book/art/cover.svg and cover.png for *LaraFly by Example*.
+"""Validate canonical cover artwork; --render regenerates PNGs from outlined SVGs.
-Reuses the firefly/spark brand motif from docs/assets/larafly-banner.svg
-(a glowing firefly abdomen + wings + spark trail โ drawn from SVG primitives,
-no external font or logo asset required) rather than PyFly's hex-network cover,
-so the book cover matches the framework's existing brand language.
+The source of the artwork is documented in book/art/PROVENANCE.md. This command
+never creates a design or substitutes system fonts.
"""
from __future__ import annotations
-from pathlib import Path
-
-ART = Path(__file__).resolve().parents[1] / "art"
-W, H = 1500, 2100 # 7.5 x 9.25 in at 200 dpi
-
-# ---------------------------------------------------------------------------
-# Palette (matches docs/assets/larafly-banner.svg)
-# ---------------------------------------------------------------------------
-BG_TOP = "#0f172a"
-BG_BOTTOM = "#1e293b"
-INK = "#1f2937"
-INK_DEEP = "#111827"
-SPARK = "#fbbf24"
-SPARK_LT = "#fde68a"
-WING = "#e0f2fe"
-SLATE = "#94a3b8"
-SLATE_DIM = "#64748b"
-CLOUD = "#cbd5e1"
-LIGHT = "#f8fafc"
-LIGHT_DIM = "#e2e8f0"
-
-FONT = "-apple-system,Segoe UI,Roboto,Helvetica Neue,Helvetica,Arial,sans-serif"
-
-
-def _firefly_glyph(cx: float, cy: float, scale: float) -> str:
- """The firefly/spark emblem, scaled + centered at (cx, cy)."""
- s = scale
- return f'''
-
-
-
-
-
-
-
-
-
-
-
-
-
- '''
-
-
-def build_svg() -> str:
- parts: list[str] = []
-
- # 1. Background gradient fill
- parts.append(f'')
-
- # 2. Faint particle texture
- parts.append(f'')
- for row in range(14):
- for col in range(6):
- gx = 60 + col * 280 + (70 if row % 2 else 0)
- gy = 90 + row * 155
- parts.append(f'')
- parts.append(f'')
- parts.append('')
- # 3. Publisher label โ top edge
- parts.append(
- f''
- f'FIREFLY SOFTWARE SOLUTIONS INC.'
- )
-
- # 4. Firefly glyph โ large, centered in the illustration zone
- parts.append(_firefly_glyph(W / 2, 620, 4.6))
-
- # 5. Divider rules
- RULE_Y = 1130
- parts.append(f'')
- parts.append(f'')
-
- # 6. Title block: "LaraFly" (Fly in spark amber) + "by Example"
- TY = RULE_Y + 150
- parts.append(
- f'LaraFly'
- )
- parts.append(
- f'by Example'
- )
-
- # 7. Subtitle (two lines)
- SUB_Y = TY + 230
- for i, line in enumerate([
- "Hexagonal PHP Microservices",
- "on Laravel 13 with the Firefly Framework",
- ]):
- parts.append(
- f'{line}'
- )
-
- # 8. Footer credit
- parts.append(
- f'Copyright (c) 2026 Firefly Software Solutions Inc.'
- )
-
- defs = (
- ''
- ''
- f''
- ''
- ''
- f''
- f''
- ''
- ''
- )
-
- svg = (
- f'\n{defs}\n' + "\n".join(parts) + "\n"
- )
- return svg
-
-
-def _render_png(svg_path: Path, png_path: Path) -> None:
- """Rasterize the cover with cairosvg (book/build/requirements.txt)."""
- import cairosvg
- cairosvg.svg2png(url=str(svg_path), write_to=str(png_path), output_width=W, output_height=H)
+import argparse
+from pathlib import Path
+from xml.etree import ElementTree
+from PIL import Image
-def main() -> None:
- ART.mkdir(parents=True, exist_ok=True)
- svg = build_svg()
- (ART / "cover.svg").write_text(svg, encoding="utf-8")
- _render_png(ART / "cover.svg", ART / "cover.png")
- print("wrote cover.svg and cover.png")
+ART = Path(__file__).resolve().parents[1] / "art"
+NAMES = ("cover", "cover-es", "back-cover", "back-cover-es")
+WIDTH, HEIGHT = 1500, 1850
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--render", action="store_true", help="Rasterize the validated SVG sources.")
+ args = parser.parse_args(argv)
+ for name in NAMES:
+ path = ART / f"{name}.svg"
+ root = ElementTree.parse(path).getroot()
+ if (root.tag != "{http://www.w3.org/2000/svg}svg"
+ or tuple(float(n) for n in root.get("viewBox", "").split()) != (0, 0, WIDTH, HEIGHT)
+ or root.get("width") != str(WIDTH) or root.get("height") != str(HEIGHT)):
+ raise ValueError(f"Expected 1500 x 1850 canonical SVG: {path}")
+ if any(node.tag == "{http://www.w3.org/2000/svg}text" for node in root.iter()):
+ raise ValueError(f"Cover typography must be outlined, not font-dependent: {path}")
+ if args.render:
+ import cairosvg
+ for name in NAMES:
+ target = ART / f"{name}.png"
+ temporary = target.with_suffix(".png.tmp")
+ try:
+ cairosvg.svg2png(url=str(ART / f"{name}.svg"), write_to=str(temporary),
+ output_width=WIDTH, output_height=HEIGHT)
+ temporary.replace(target)
+ finally:
+ temporary.unlink(missing_ok=True)
+ for name in NAMES:
+ path = ART / f"{name}.png"
+ with Image.open(path) as image:
+ if image.format != "PNG" or image.size != (WIDTH, HEIGHT):
+ raise ValueError(f"Expected 1500 x 1850 canonical PNG: {path}")
+ image.verify()
+ print("Verified four localized front/back cover pairs.")
+ return 0
if __name__ == "__main__":
- main()
+ raise SystemExit(main())
diff --git a/book/build/md.py b/book/build/md.py
index 3a2708c5..1c3f3a6c 100644
--- a/book/build/md.py
+++ b/book/build/md.py
@@ -1,4 +1,4 @@
-"""Markdown -> HTML for *LaraFly by Example*.
+"""Markdown -> HTML for *larafly by example*.
Custom block directives on top of python-markdown:
::: figure |
(single line; inlines the SVG)
diff --git a/book/build/pdf.py b/book/build/pdf.py
index 368478f9..12bd8ff1 100644
--- a/book/build/pdf.py
+++ b/book/build/pdf.py
@@ -1,10 +1,38 @@
"""Render the assembled book HTML to a PDF via WeasyPrint."""
from __future__ import annotations
+
+import base64
+import re
+import xml.etree.ElementTree as ET
+from functools import lru_cache
from pathlib import Path
-from weasyprint import HTML, CSS
+
+from weasyprint import CSS, HTML
+
+_LOGO = re.compile(r"(.*?)", re.S)
+
+
+@lru_cache(maxsize=64)
+def _print_logo(source: str) -> str:
+ """Keep the approved gradient/clip artwork exact without rasterizing diagram text."""
+ import cairosvg
+
+ root = ET.fromstring(source)
+ box = {name: root.get(name) for name in ("x", "y", "width", "height")}
+ root.attrib.pop("x", None)
+ root.attrib.pop("y", None)
+ width = 800
+ height = round(width * float(box["height"]) / float(box["width"]))
+ png = cairosvg.svg2png(bytestring=ET.tostring(root), output_width=width, output_height=height)
+ uri = "data:image/png;base64," + base64.b64encode(png).decode("ascii")
+ attributes = " ".join(f'{name}="{value}"' for name, value in box.items())
+ return f''
+
def render_pdf(full_html: str, base_url: Path, css_paths: list[Path], out: Path) -> Path:
- out = Path(out); out.parent.mkdir(parents=True, exist_ok=True)
+ out = Path(out)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ full_html = _LOGO.sub(lambda match: _print_logo(match[1]), full_html)
HTML(string=full_html, base_url=str(base_url)).write_pdf(
- str(out), stylesheets=[CSS(filename=str(p)) for p in css_paths])
+ str(out), stylesheets=[CSS(filename=str(path)) for path in css_paths])
return out
diff --git a/book/src-es/00-front/00-title.md b/book/src-es/00-front/00-title.md
index f572fdc2..5104d084 100644
--- a/book/src-es/00-front/00-title.md
+++ b/book/src-es/00-front/00-title.md
@@ -1,5 +1,5 @@
-# LaraFly by Example {.chtitle}
+# larafly by example {.chtitle}
-### Microservicios PHP Hexagonales en Laravel 13 con el Firefly Framework
+### Arquitectura de negocio y polรญticas de aplicaciรณn sobre Laravel
**Firefly Software Solutions Inc.**
diff --git a/book/src/00-front/00-title.md b/book/src/00-front/00-title.md
index 2aca7324..be3f9e21 100644
--- a/book/src/00-front/00-title.md
+++ b/book/src/00-front/00-title.md
@@ -1,5 +1,5 @@
-# LaraFly by Example {.chtitle}
+# larafly by example {.chtitle}
-### Hexagonal PHP Microservices on Laravel 13 with the Firefly Framework
+### Business architecture and application policy on Laravel
**Firefly Software Solutions Inc.**
diff --git a/book/tests/test_covers.py b/book/tests/test_covers.py
new file mode 100644
index 00000000..4e92ea95
--- /dev/null
+++ b/book/tests/test_covers.py
@@ -0,0 +1,166 @@
+"""Covers survive publication as accessible bookends, without print margins."""
+
+import shutil
+import sys
+import zipfile
+from pathlib import Path
+from xml.etree import ElementTree as ET
+
+import pdfplumber
+import pytest
+import yaml
+from PIL import Image
+
+from build import build as book_build
+from build import gen_cover
+
+BOOK = Path(__file__).resolve().parents[1]
+NS = {"opf": "http://www.idpf.org/2007/opf", "html": "http://www.w3.org/1999/xhtml"}
+
+
+@pytest.fixture
+def edition(tmp_path, monkeypatch):
+ source = tmp_path / "book"
+ source.mkdir()
+ (source / "art").mkdir()
+ (source / "manuscript").mkdir()
+ (source / "manuscript" / "chapter.md").write_text("# A short chapter\n\nThe business application.\n")
+ for name, color in (("cover-es.png", "#112233"), ("back-cover-es.png", "#445566")):
+ Image.new("RGB", (1500, 1850), color).save(source / "art" / name)
+ cfg = {
+ "title": "Libro & ejemplo", "subtitle": "Sistemas & negocio", "author": "Firefly", "language": "es",
+ "identifier": "urn:uuid:cover-test", "output_basename": "edition",
+ "manuscript_dir": "manuscript", "cover_png": "art/cover-es.png",
+ "cover_alt": 'Portada de "Libro & ejemplo"', "back_cover_png": "art/back-cover-es.png",
+ "back_cover_alt": "Contraportada: arquitectura & aplicaciones",
+ "labels": {"contents": "Contenido", "cover": "Portada", "back_cover": "Contraportada"},
+ "parts": [{"title": "Parte I โ Aplicaciones", "chapters": [
+ {"id": "ch01", "file": "chapter.md", "num": 1, "title": "Aplicaciones"}
+ ]}],
+ }
+ (source / "book.yaml").write_text(yaml.safe_dump(cfg, allow_unicode=True))
+ out = tmp_path / "output"
+ monkeypatch.setattr(book_build, "BOOK", source)
+ monkeypatch.setattr(book_build, "THEME", BOOK / "theme")
+ monkeypatch.setattr(book_build, "DIST", out)
+ return source, out, cfg
+
+
+def test_epub_has_localized_cover_bookends_and_one_cover_image(edition):
+ source, out, cfg = edition
+ assert book_build.main([]) == 0
+ with zipfile.ZipFile(out / "edition.epub") as archive:
+ opf = ET.fromstring(archive.read("OEBPS/content.opf"))
+ spine = [item.get("idref") for item in opf.findall("opf:spine/opf:itemref", NS)]
+ assert spine[0] == "cover"
+ assert spine[-1] == "back-cover"
+ covers = opf.findall('opf:manifest/opf:item[@properties="cover-image"]', NS)
+ assert len(covers) == 1
+ assert archive.read("OEBPS/" + covers[0].get("href")) == (source / cfg["cover_png"]).read_bytes()
+ for doc, alt in (("cover", cfg["cover_alt"]), ("back-cover", cfg["back_cover_alt"])):
+ xhtml = ET.fromstring(archive.read(f"OEBPS/{doc}.xhtml"))
+ img = xhtml.find(".//html:img", NS)
+ assert img is not None
+ assert img.get("alt") == alt
+ assert "OEBPS/" + img.get("src") in archive.namelist()
+ nav = ET.fromstring(archive.read("OEBPS/nav.xhtml"))
+ links = [(a.get("href"), a.text) for a in nav.findall(".//html:a", NS)]
+ assert links[0] == ("cover.xhtml", "Portada")
+ assert links[-1] == ("back-cover.xhtml", "Contraportada")
+
+
+def test_pdf_covers_fill_first_and_last_page_without_headers(edition):
+ _, out, _ = edition
+ assert book_build.main([]) == 0
+ with pdfplumber.open(out / "edition.pdf") as pdf:
+ assert len(pdf.pages) == 5 # front, contents, part divider, chapter, back
+ for page in (pdf.pages[0], pdf.pages[-1]):
+ assert (page.width, page.height) == pytest.approx((540, 666))
+ assert not page.chars
+ assert len(page.images) == 1
+ img = page.images[0]
+ assert (img["x0"], img["top"], img["x1"], img["bottom"]) == pytest.approx((0, 0, 540, 666))
+ assert "business application" in (pdf.pages[-2].extract_text() or "")
+
+
+@pytest.mark.parametrize("key", ["cover_png", "back_cover_png"])
+def test_missing_configured_cover_fails_before_publishing(edition, key):
+ source, out, cfg = edition
+ (source / cfg[key]).unlink()
+ with pytest.raises(FileNotFoundError, match=Path(cfg[key]).name):
+ book_build.main([])
+ assert not out.exists()
+
+
+@pytest.fixture
+def canonical_art(tmp_path, monkeypatch):
+ art = tmp_path / "art"
+ art.mkdir()
+ for name in ("cover", "cover-es", "back-cover", "back-cover-es"):
+ (art / f"{name}.svg").write_text(
+ ''
+ ''
+ )
+ Image.new("RGB", (1500, 1850), "#123456").save(art / f"{name}.png")
+ # The legacy PyFly generator loads its logo before overwriting the cover.
+ (art / "logo").mkdir()
+ shutil.copyfile(art / "cover.png", art / "logo" / "pyfly-logo.png")
+ monkeypatch.setattr(gen_cover, "ART", art)
+ monkeypatch.setattr(sys, "argv", ["gen_cover.py"])
+ return art
+
+
+def test_cover_command_preserves_canonical_art(canonical_art):
+ before = {p.name: p.read_bytes() for p in canonical_art.iterdir() if p.is_file()}
+ gen_cover.main()
+ assert {p.name: p.read_bytes() for p in canonical_art.iterdir() if p.is_file()} == before
+
+
+@pytest.mark.parametrize("invalid", ["geometry", "text"])
+def test_cover_command_rejects_noncanonical_svg(canonical_art, invalid):
+ svg = canonical_art / "cover.svg"
+ original = svg.read_text()
+ bad = original.replace("1500 1850", "1500 2100") if invalid == "geometry" else original.replace("