xm/symfony-bundle
XM Symfony Bundle provides a Symfony integration layer for XM, including bundle registration, service definitions, and autowiring support, making it easy to add XM features to Symfony applications with minimal configuration.
This is an internal bundle (0 stars, 0 dependents) that encodes XM Media's house style for Event Sourcing + CQRS + GraphQL on top of Prooph and Overblog. Treat it as an opinionated framework, not a library: most of the value is in the base classes you extend and the make:model generator, not in callable APIs.
1. Read agents/AGENTS.md first. It is the de facto manual. The rest of this document is essentially an index into it, so skim the whole thing before writing code.
2. Install and register. Add the private repository, composer require xm/symfony-bundle, then enable the bundle in config/bundles.php. Two features are on by default and will immediately change behaviour in an existing app:
# config/packages/xm_symfony.yaml
xm_symfony:
session_expiry: true # default: true
maintenance: true # default: true
maintenance:
file: '%kernel.project_dir%/var/maintenance'
time_zone: 'Europe/London'
If either is unwelcome, disable it in the same PR that adds the bundle so nobody debugs a phantom idle-logout.
3. Look at these files next, in this order:
| Path | Why |
|---|---|
EventSourcing/Aggregate/AggregateRoot.php |
The class every domain model extends |
Messaging/DomainMessage.php |
Base for all Commands and DomainEvents |
Maker/AggregateRootMaker.php |
Generates a whole vertical slice |
Model/ |
ValueObject, Entity, UuidId, ValueObjectCollection |
EventStore/Metadata*Enricher.php |
Where IP/user-agent/issuer metadata comes from |
4. First real use case: scaffold one aggregate end-to-end. Do not hand-write the stack for your first feature — generate it and study the output:
bin/console make:model
This produces the aggregate root, three commands (Add, Change, Delete), three events (WasAdded, WasChanged, WasDeleted), handlers, repository, projection, read model, Doctrine entity + finder, GraphQL types/queries/mutations, YAML config and a test suite. The generated code is the best documentation in the repo — keep one generated model around as a reference before you start deleting parts you don't need.
5. Then run the seven manual steps listed below under Gotchas. Generation is only half the job; a fresh model is inert until the event stream exists and the projection is wired.
Everything scalar-ish should be a ValueObject: immutable, self-validating, no setters. The bundle ships Email, PhoneNumber, PostalCode, Country, Province — use them rather than re-validating strings in controllers or forms.
Identifiers extend UuidId and implement UuidInterface: they auto-generate and give you type safety, so a UserId can never be passed where an OrderId is expected.
final class UserId extends UuidId {}
$user = User::add(UserId::generate(), new Email('[email protected]'));
For typed lists use ValueObjectCollection rather than array — it keeps the boundary honest and makes the aggregate's invariants testable.
The AggregateRoot constructor is protected; the only way in is a static factory. The pattern is always:
final class User extends AggregateRoot
{
use EventProducerTrait;
use EventSourcedTrait;
private Email $email;
public static function add(UserId $id, Email $email): self
{
$user = new self();
$user->recordThat(UserWasAdded::with($id, $email));
return $user;
}
protected function applyUserWasAdded(UserWasAdded $event): void
{
$this->email = $event->email();
}
}
Two rules worth internalising: state changes only inside apply* methods, and recordThat is the only mutation path. If a setter appears on an aggregate, the event log and the in-memory object have diverged and replay will break.
Both extend DomainMessage and therefore carry UUID, message name, timestamp, metadata and payload. Keep them dumb: constructor-promoted readonly properties, named constructors (::with(...)), and accessors that cast the payload. No logic, no dependencies.
The message flow is fixed and worth memorising, because debugging always means asking "which box am I in?":
GraphQL mutation → Command dispatched on Messenger → CommandHandler
→ AggregateRepository::get() → aggregate emits event
→ EventStore persists → projection updates Doctrine read model
→ GraphQL query reads Doctrine entity
Command handlers should be thin: load, call a domain method, persist. Anything else belongs in the aggregate.
Projections are not the write model — they're Doctrine entities you query directly. Two conventions:
Because the write side and read side are separate, expect a visible lag in tests and in dev. In integration tests, either run the projection synchronously or poll; don't assume read-after-write.
Queries implement Overblog\GraphQLBundle\Definition\Resolver\QueryInterface. Mutations resolve to a service that builds and dispatches a command — no domain logic in the resolver. Custom scalars live in Infrastructure/GraphQl/Type/ (DateType, UuidType, GenderType); reuse them instead of redefining DateTime per project.
Errors: throw a CodedUserError subclass (NotFoundError, LinkExpiredError, TooManyRequestsError) and the frontend receives an Apollo-style extensions.code. To add a code, subclass CodedUserError and return it from errorCode(). Don't invent codes ad hoc — each frontend keeps its own list and they will drift.
Note the subscriber (GraphQlErrorSubscriber) is opt-in: a project must register it. That's deliberate, since it changes the shape of what clients receive.
This is the feature most likely to be used on day one. It's file-driven, so it needs no deploy:
bin/console app:maintenance on --allow-my-ip
bin/console app:maintenance status
bin/console app:maintenance off
The JSON file holds message, end time, allowed IPs/CIDR ranges and the bypass key. on renders a standalone page to <file>.html; off deletes both. Bypass for yourself via ?maintenance-key=… (sets a cookie and redirects without the key), or send X-Maintenance-Key from a script.
For a fully locked-down app where the kernel won't boot, wire the gate into the front controller before kernel construction:
return static fn (array $context): Kernel|Response
=> MaintenanceGate::handleGlobals(dirname(__DIR__).'/var/maintenance')
?? new Kernel($context['env'], $context['debug'], $context);
Messenger workers pause (not exit) while maintenance is on — intentional, so a supervisor doesn't restart-loop them.
On by default. It exists because PHP's file session GC doesn't check a session's age on read, so idle users stay logged in. Every authenticated request extends the session except routes declaring:
#[Route('/api/session/time-left', defaults: ['_extend_session' => false])]
Use that attribute on any polling/heartbeat endpoint, or "time remaining" will never count down. Users with a remember-me cookie are exempt; the bundle discovers remember-me config from the tagged security.remember_me_handler services, so nothing to configure.
ContentDisposition::make($disposition, $filename) — any user-supplied filename. Prefer it over Symfony's HeaderUtils::makeDisposition(), which throws on non-ASCII, /, \ and %. It transliterates for the ASCII fallback.PHPUnit 13 + Mockery, tests under Tests/ mirroring src/. Use attributes, not docblock annotations, and static data providers:
#[DataProvider('invalidEmails')]
public function testRejectsInvalidEmail(string $email): void { /* ... */ }
public static function invalidEmails(): iterable { yield ['nope']; }
Test aggregates by asserting on getRecordedEvents() — that covers both the emitted event payload and the apply behaviour in one test.
make:model output is not runnable on its own. You must still: (1) add the repository to config/packages/event_sourcing.yaml; (2) add the projection to config/packages/prooph_event_store.yaml; (3) create the stream with bin/console event-store:event-stream:create <stream_name>; (4) add the table constant to App\Projection\Table; (5) map the events in App\Messenger\RunProjectionMiddleware; (6) update GraphQL permissions; (7) regenerate the schema with bin/console app:graphql:dump-schema. Skipping 3 gives you a write that silently goes nowhere; skipping 5 gives you an event log with a permanently stale read model. Put these seven steps in your PR template.
Protected constructors. AggregateRoot's constructor is protected and domain messages expect named constructors. If you find yourself adding a public constructor or a setter, you're fighting the pattern — the event replay will diverge from live behaviour and the bug will only appear after a deploy.
Constructor injection with readonly properties, plus declare(strict_types=1); in every file and aligned => in arrays. All three are enforced by composer cs; run it before review or CI will. composer cs:fix handles alignment automatically.
Maintenance file must survive deploys. Default %kernel.project_dir%/var/maintenance is often wiped by a release. If maintenance mode "turns itself off" after a deploy, this is why — move the file to persistent storage.
An empty or unreadable maintenance file means ON with default settings. Handy for touch-based emergencies, but confusing if a truncated write leaves a zero-byte file: the app appears down with no custom message. off deletes the file rather than emptying it for exactly this reason.
IP allow-listing depends on your proxy config. getClientIp() is only correct with framework.trusted_proxies set (or mod_remoteip). Behind a load balancer without it, every request appears to come from the balancer and --allow-ip matches nothing or everything.
The bypass key is regenerated every time maintenance is turned on. Scripts holding the old URL break; use --new-key deliberately and re-distribute. The key arrives in a query string first (then cookie, session-scoped until browser close) — don't log query strings on that route.
A hand-created maintenance file has no rendered page. MaintenanceGate falls back to a plain 503 body. If you want the styled page, turn it on via the console command, which renders <file>.html.
GraphQL JSON clients get a different shape. For /graphql… and JSON-accepting requests, maintenance returns extensions.code: MAINTENANCE as a network error (503 before GraphQL runs), so it lands in Apollo's networkError.result, not graphQLErrors. Frontends that only handle graphQLErrors will show a blank screen.
Register GraphQlErrorSubscriber explicitly. It's opt-in by design; if your UNAUTHENTICATED/FORBIDDEN codes never arrive, the subscriber isn't wired up.
"Did you mean …" leaks your schema. The error subscriber hides it from anonymous users but not in debug. Verify behaviour with APP_DEBUG=0 before shipping; the suggestion text is a real information disclosure.
Session expiry exempts remember-me users. That's correct for UX but means a stolen remember-me cookie outlives the idle timeout. Set framework.remember_me lifetimes accordingly rather than assuming this bundle enforces them.
Two MaintenanceSubscriber runs happen per request (front controller, then kernel.request at priority 255). The second is intentional — it catches maintenance being switched on mid-flight and sets the bypass attribute on the kernel's own request object. Don't remove it thinking it's redundant.
Event stream naming. Create the stream before the first command, and treat the name as permanent: renaming it orphans history. Generate it once, name it <context>-<aggregate> and never touch it.
Custom GraphQL types belong in Infrastructure/GraphQl/Type/. Redefining DateTime or UUID per project causes Overblog schema conflicts that surface as confusing "type already defined" errors at dump time, not at runtime.
Debugging checklist for a "command did nothing": is the stream created? is the handler registered by Makefile/autoconfigure? does RunProjectionMiddleware map the event? did you dump the GraphQL schema? In that order — it's the cheapest sequence.
How can I help you explore Laravel packages today?