Symfony Backoffice Blueprint
A working Symfony back office built on Flow PHP. Read it, run it, take the patterns to your own project.
This is a real Symfony 7.4 application, not a tutorial repository. It manages products, categories, customers, orders, reviews, warehouses and stock for an imaginary shop, and it uses that boring domain as an excuse to show how the hard parts fit together.
Three things it focuses on.
One schema, four jobs
Every model has a schema() method that returns a Flow Type. Nothing else
describes the shape of that model. Decorated with a bit of metadata, that one definition is what
generates:
- ✓ The PostgreSQL table, with its column types, lengths, defaults, indexes and foreign keys
- ✓ The OpenAPI 3.1 specification you can browse at /api
- ✓ The expected CSV layout on the import page, including generated sample rows
- ✓ Runtime validation of every row on its way out of the database
Add a column in one place and the migration, the API contract, the import format and the validation all follow. There is no second definition to keep in sync.
Data processing that streams
Imports run through a Flow DataFrame. You upload a CSV, it lands in S3 compatible storage, a Messenger
job picks it up and runs the pipeline inside a transaction with batched upserts. Row counts and errors
are written back to an imports table you can watch from the UI.
Exports and the API stream properly. /export/products.csv, .json and
.xml go straight from a server side PostgreSQL cursor to the response without building an
array in memory, and so does /api/{resource}/stream. The paginated endpoints use keyset
pagination with opaque cursors, not OFFSET.
PostgreSQL, typed properly
There is no ORM, and there is not a single SQL string in src. Every query is built
programmatically with the flow-php/postgresql query builder, from a plain
count(*) up to the keyset pagination that compares a row constructor against a bound
cursor. Columns, joins, predicates and ordering are typed expressions, so renaming a column is a
static analysis error instead of a runtime one, and there is nowhere for a concatenated string to
sneak in.
Results come back through the model schema, so what you get out of fetchAllInto() is a
typed object and not an array of mixed. Static analysis can follow your data from the
query all the way to the template.
Migrations are the one place that holds plain SQL, and deliberately so. A migration is something you
have to be able to open and change by hand, whether that is a backfill, an index built concurrently or
a one-off data fix, so it stays as system queries you control.
flow:migrations:diff writes the first draft for you by comparing the database to the
catalog, and from there the file is yours.
One PostgreSQL instance runs everything. Data, cache pools, sessions, the Messenger transport and migrations. No Redis, no RabbitMQ.
Static analysis is Mago on PHP 8.5. There are seven suppressions in the entire src tree.
Telemetry from the browser to the query
Most PHP applications stop at Monolog. This one implements OpenTelemetry end to end. Spans are emitted for the HTTP kernel, the controller and its argument resolvers, every SQL query, every Twig template, cache operations, the HTTP client, Messenger consumers and console commands.
The browser is instrumented too. The server renders its trace context into a meta tag, so the
documentLoad span from JavaScript ends up in the same trace as the SQL query that rendered
the page. One waterfall, front to back.
None of which is much use while you are still writing the feature, so it is wired into the Symfony Profiler as well. Every request gets a Flow Telemetry panel with the span count, the total trace time, a timeline of that request, and the full list of spans with their kind, scope, duration and attributes. The metrics and logs the request emitted are on the same page. You inspect signals in the toolbar while you work, and the same signals go to the collector when you are done.
If you have ever tried to wire OTEL into Symfony by hand, the configuration is the part to read first. Named loggers, meters and tracers with their own scope attributes, a log processing pipeline with severity and attribute filtering, batching, and a failover exporter that writes to disk when the collector is unreachable. It all ships to an OTEL Collector and lands in OpenObserve, which comes up with the rest of the stack.
What's in the repository
- ✓ A complete Symfony 7.4 application, over 200 classes, organised in vertical slices with separated read and write models
- ✓ 364 test methods split into unit and integration suites, plus mutation testing configured at a 97% minimum covered MSI
- ✓ Seventeen Flow PHP packages wired up and actually used, not just installed
- ✓ Docker and nix development environments sharing the same PHP and web server configuration
- ✓ Generated fixtures loaded through ETL pipelines, so you get a populated database with one command
- ✓ compose.yaml with PostgreSQL, the OTEL Collector, OpenObserve and S3 compatible storage
- ✓ Updates as the blueprint evolves, at no extra cost
What it is not
It is a blueprint, not a product. There is no authentication or authorization layer, the HTML is deliberately plain because the front end is not what this teaches, and you should not deploy it as-is. It exists so you can read it, run it, pull it apart, and copy what works into your own codebase.