symfony-cmf/routing-auto
Symfony CMF RoutingAuto automatically creates and updates routes for your content objects. It hooks into persistence events and uses configurable route generators, keeping URLs in sync with your documents without any manual route management.
This is a Symfony CMF component, not a Laravel package — the mental model is closest to Eloquent observers + a catch-all route that resolves a slug from the database, except the "database" is a Doctrine AutoRoute table maintained by event listeners. Read the code with that mapping in mind:
| RoutingAuto concept | Laravel equivalent |
|---|---|
RoutingAutoManager |
an observer/orchestrator service |
RouteMaker |
the code in your saved observer |
AutoRouteProvider |
a catch-all route resolver |
UriGenerator + token providers |
spatie/laravel-sluggable + a URL builder |
| Defunct routes | old slugs kept for 301s |
Minimum viable setup (Symfony app):
composer require symfony-cmf/routing-auto symfony-cmf/routing-auto-bundle
Pin the versions. The last release is 2020-10-28 and the repo is archived; expect Symfony 3.4/4.x-era constraints and doctrine/* conflicts on modern projects.CmfRoutingAutoBundle in config/bundles.php (or AppKernel), and make sure the AutoRoute entity/document mapping is loaded (doctrine.orm.mapping / auto-mapping in the bundle config).bin/console doctrine:schema:update --force or generate a migration for the auto_route table (columns for path, locale, content_class, content_id, type, name).cmf_routing_auto:
auto_route_mapping:
App\Entity\Article:
route_schema:
path: /articles/[content_method:method=getTitle]
Article with a title, then check SELECT * FROM auto_route; and hit the URL through the CMF chain router.First use case to prove out: create → edit title → confirm the old row becomes "defunct" and the new path works. That one loop exercises the manager, URI generator, conflict resolver and defunct handler together, and it's where 90% of the surprises live.
Read these classes first, in this order: RoutingAutoManager, RouteMaker, UriGenerator, AutoRouteProvider, then the bundle's DependencyInjection/Configuration.php — that last file is the only reliable documentation for the config keys.
Token-driven route schemas. The path is a template resolved by token providers at persist time. The built-ins are a content-method provider (call a getter), a content-object provider (the object itself, usually for the ID) and a locale provider; the syntax is roughly [content_method:method=getTitle], [content_object], [content_locale]. Build the path from stable, already-slugified getters — never from getBody() or anything user-editable in a way that changes wildly.
Custom token providers. Implement TokenProviderInterface (provideValue(array $arguments, $object, $env) returning a string or null) and register it as a service tagged for the bundle's compiler pass. Typical uses: a getSlug() accessor, a SKU, a category hierarchy segment, or a translated title.
Nested content / parent paths. For a tree (Category → Page → Article), chain routes through the content path config so a child inherits the parent's URI. This forces a top-down persistence discipline: a child cannot resolve its parent's path if the parent has no AutoRoute yet. In seeders and importers, iterate depth-first and flush per level; never flush() a whole graph and hope the listener ordering works out.
Multi-locale / multi-schema. Declare several named schemas per class (one per locale, or a "short" and "canonical" URL). Each becomes its own AutoRoute row with a different name/locale. Pick the active one in a request listener or via the locale on the content object.
Conflict resolution. Two articles titled "Hello World" will collide. Configure the conflict resolver (auto_increment-style suffixing vs. throw) and make the decision deliberately: silent suffixing is great for CMS authors and terrible for SEO audits. Whatever you choose, log conflicts — the resolver firing is usually a symptom of a missing unique field in the schema.
Defunct routes are the whole point — don't delete them. The default handler tends to clean up old routes; keep them, store a timestamp, and resolve them in a kernel.exception listener (or Laravel: a 404 handler) to issue a 301 to the current route for the same content. Without this, every title edit is a broken link.
Keep the write path transactional and single-flighted. The manager persists routes in response to Doctrine lifecycle events, which means a listener can flush and re-trigger events. Wrap content saves in one transaction/unit of work and add a re-entrancy guard (a flag on the manager or a decorator) if you see duplicate auto_route rows. On Laravel you'd do this with DB::transaction() + withoutEvents() around bulk imports.
Custom storage. If your content lives outside Doctrine ORM/PHPCR-ODM, implement AdapterInterface (the adapter is what translates content objects into the metadata the manager needs). This is the sanctioned extension point and the cleanest way to reuse the URI/conflict machinery.
Testing. Unit-test the token providers and conflict resolver in isolation; for the rest, write a functional test that boots the kernel, persists an object, and asserts on the auto_route row count, path, and defunct status. Assertions against the repository are far more stable than asserting on the router.
symfony-cmf/routing is archived too. Treat it as vendored code you now own: fork it, or budget for extraction.debug:router story that helps you. Routes are database rows, not compiled definitions. Debug by querying auto_route directly and calling the manager's URI generation in a console command or dump() in the listener.postPersist vs postUpdate timing. On postPersist the ID exists but the transaction is still open; on postUpdate the old values are already gone, so you must read getEntityChangeSet() (or the equivalent) to know the previous slug before it's overwritten. Capture the old path in a preUpdate listener if you need redirects./articles//, doubled separators, or empty segments). Return '', normalize, and assert the final URI is non-empty and starts with / before persisting./ in a title will create phantom path segments).AutoRouteProvider caches lookups by route name; in dev, changes made directly in SQL won't appear until you clear it. In prod, warm the cache after content imports.route_schema, content_path, exists_action / not_exists_action, conflict_resolver) has changed across versions and the README lags. DependencyInjection/Configuration.php is the source of truth for the version you pinned — verify keys there before debugging why nothing is generated.exists_action/not_exists_action pair is the fix for importers. use existing routes rather than recreating the tree on every run; create only when you genuinely want new nodes. Getting this wrong duplicates whole subtrees on re-import.TokenProviderInterface, ConflictResolverInterface, DefunctRouteHandlerInterface, and AdapterInterface. Those four cover essentially every customization need — you should rarely need to subclass the manager itself.slug unique, path), a Route model with a saved observer generating the canonical URL, a Route::get('{path}')->where('path', '.*') fallback, and a route_redirects table written in the same transaction. You'll get the same behavior in ~150 lines with none of the archived-dependency risk.How can I help you explore Laravel packages today?