A Symfony app that exposes an HTTP API can have that API documented directly from the code: tooling reads the controllers and routes and produces an OpenAPI spec. The framework knows every endpoint, so the documentation is derived from the code rather than maintained alongside it.
Event-driven systems have contracts too, which messages flow, what their payload looks like, where they go, but nothing in the framework enumerates them. No generator reads the messages off the framework the way it reads HTTP routes, so often the contract is either left undocumented or written and maintained by hand.
AsyncAPI is the standard that fills the gap: like OpenAPI, but for messages over a broker (AMQP, Kafka, SQS, …) instead of REST APIs. zeusi/asyncapi-bundle is a Symfony bundle that generates AsyncAPI 3.x documentation from your application's code.
What an AsyncAPI document describes
An AsyncAPI document captures, in a machine-readable spec:
- messages, the events/commands that flow, each with a payload schema;
- channels, the addressable medium they flow through;
- operations, whether your app sends or receives each message;
- servers, the brokers involved, and their protocol.
Once it exists, you get the same things OpenAPI gives you for REST: a browsable UI, a contract consumers can code against, schema validation, and a single source of truth that can live next to the code.
Why events are harder to document than a REST API
REST tooling has a natural anchor: routes. Every endpoint is registered with the framework, so a generator can enumerate controllers and reflect their input/output. There's a definitive list to walk.
An event-driven app has no equivalent. A message might reach the broker through a client library, a vendor SDK, or a plain HTTP call, there's no single point the framework owns, so nothing holds a registry that says "these are the events this service publishes, and this is their shape". AsyncAPI itself doesn't impose one either. So the document is either written by hand and kept in sync manually, or derived from a convention the tooling can read.
This bundle takes the pragmatic route, on one assumption: that each message is represented by a PHP class, a DTO. The class is the anchor: the payload schema is derived from it, so the documentation can't drift from the code. The remaining question, which classes are messages, is answered, by default, with an attribute on the class, which also carries the metadata the code can't infer, like the channel a message belongs to.
Installation and setup
composer require zeusi/asyncapi-bundle
Minimal configuration is just your document's info:
# config/packages/asyncapi.yaml
asyncapi:
document:
info:
title: 'DemoEvents'
version: '1.0.0'
And the routes for the two endpoints (more on those below):
# config/routes/asyncapi.yaml
asyncapi:
resource: '@AsyncApiBundle/Controller/AsyncApiController.php'
type: attribute
That's the whole setup. Now let's document something.
Declaring a message
Mark the DTO that represents your event with #[AsyncApiMessage]:
use Zeusi\AsyncApiBundle\Attribute\AsyncApiMessage;
#[AsyncApiMessage(channel: 'bookings', summary: 'A trip was booked', tags: ['travel'])]
final class TripBooked
{
public function __construct(
public readonly string $bookingId,
public readonly int $travelers,
) {
}
}
The bundle scans your code for the attribute, derives each payload schema from the class, and assembles a valid AsyncAPI 3.x document: a message in components.messages, grouped into a channel, with a send operation.
The schema derivation is handled by zeusi/json-schema-extractor: it reflects the class and asks a serialization strategy how the object goes over the wire. With the Symfony Serializer strategy (used when the Serializer is available), the schema reflects the shape you actually send, serialization groups, #[SerializedName], name converters, discriminators. PHPDoc and Validator constraints enrich it further when those packages are present.
The attribute carries only the things the code can't tell you, placement and human metadata (title, summary, tags, …). Everything structural comes from the class. Because the schema is derived, it can't fall out of sync with the DTO.
Exposing the document and UI
The bundle exposes:
-
GET /asyncapi.json, the generated document as JSON. Always available, and the thing your consumers (or a contract test) actually fetch. -
GET /asyncapi, an HTML UI rendered with the official AsyncAPI web component, so humans get a browsable view (this one needs Twig, installsymfony/twig-bundleto enable it).
That's the attribute workflow end to end: annotate the events, get a contract.
The Symfony Messenger integration
Symfony has no first-class notion of "a published event" to hook into. The closest thing is Messenger, it's where transports, routing and serialization are already declared, which makes it the one sensible anchor in the ecosystem. So the bundle can read your Messenger setup to fill in the parts of the document that live in your infrastructure, not in your DTOs.
It reuses Messenger's own routing semantics, the same type matching Messenger uses to pick a transport for a message, so what it documents matches where your messages actually go. There are two capabilities, both opt-in, configured under providers.messenger:
asyncapi:
providers:
messenger:
transports: ['events'] # allowlist; scopes everything below
enrichment: true
discovery: false
Enrichment: servers and content types
With enrichment: true, for each documented message routed to a transport the bundle adds:
- servers, derived from the transport DSN (host + protocol);
-
content type, taken from the transport's serializer (the Symfony Serializer yields
application/json), unless the message declared one; - channel ↔ server links, when more than one server exists, so each channel is pinned to the servers its messages actually use.
Internal transports (sync://, in-memory://, doctrine://) and failure/retry transports are left out automatically.
Discovery: messages from the routing map
With discovery: true, the routing map itself becomes a source of messages, so classes you've already routed don't need the attribute at all:
# config/packages/messenger.yaml
framework:
messenger:
routing:
'App\Message\TripBooked': events # already here for your app to work
The bundle treats the routing keys (and classes matched by interface, parent or namespace-wildcard rules) as messages, deriving their payloads from the class just like the attribute path does. The * catch-all is never a source, it matches every dispatched object and can't be enumerated, and anything you did annotate stays owned by the attribute, so its richer metadata wins.
Why the integration is opt-in
Both capabilities rest on an assumption: that the Messenger transports are the publication channel. That doesn't always hold. Messenger might be used purely for internal queues, async jobs that never leave the app, or a message might pass through an internal Messenger queue and then be published by some other mechanism the bundle can't see. In either case, documenting the Messenger transport as a public server would be misleading.
The payload extraction follows from the code and makes no assumption about how you publish. The Messenger integration, by contrast, makes a semantic guess, and a guess that can write wrong information into a public contract should be a conscious choice, not a default. Turning it on is your assertion that, for the documented transports, Messenger is how the event goes out. The transports allowlist is there to scope it precisely.
Conclusion
Generating documentation from code is what keeps it from going stale, and this bundle applies that idea to the messages a service publishes in a distributed system. Payload schemas come from your DTOs; messages are discovered from an attribute or from your Messenger configuration, which the bundle can also read to derive the brokers involved. The documentation moves when the code moves, instead of being a separate artifact to maintain.
The bundle is young, pre-1.0, so the API may still shift between minor versions.
The attribute-based workflow is the stable core, while the Messenger integration is there to draw on when it makes sense for your system.
- Package:
zeusi/asyncapi-bundle - Source & docs: github.com/antonioturdo/asyncapi-bundle
If you document events by hand today, give it a try, and if it's missing something your setup needs, issues and PRs are welcome.