runroom-packages/redirection-bundle
Redirection bundle for Symfony applications that helps manage URL redirects from a central place, so moved or renamed pages keep resolving cleanly. Useful for handling legacy paths and reducing broken links without scattering redirect rules across config.
Install and register
composer require runroom-packages/redirection-bundle
Register the bundle in config/bundles.php (it is not Flex-auto-registered):
Runroom\RedirectionBundle\RunroomRedirectionBundle::class => ['all' => true],
Create the schema. The bundle ships a Doctrine-mapped Redirection entity. Either import its mapping or copy the entity into your app (recommended if you need to extend it):
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate
Check the bundle's Resources/config/doctrine/ (or config/doctrine/) for the mapping file — entity name is typically Runroom\RedirectionBundle\Entity\Redirection.
Wire it into Sonata Admin. The bundle provides a RedirectionAdmin service. Make sure Sonata's admin registry can see it (the bundle registers it under the sonata.admin tag if Sonata is installed). Confirm with:
php bin/console debug:container --tag=sonata.admin | grep -i redirect
You should now see a "Redirections" section in the Sonata dashboard.
First use case — the bread-and-butter one. A marketing page moved and SEO is bleeding. Create a row in Sonata:
| source | destination | httpCode | active |
|---|---|---|---|
/old-landing-page |
/new-landing-page |
301 | ✓ |
Hit https://yoursite.test/old-landing-page in a private window and confirm you land on /new-landing-page with a 301. That's the entire value proposition of this bundle — an admin-editable redirect table that the kernel listener consults on every request. Everything below is about not getting bitten by the edges of that.
Where to read first in the repo:
EventListener/ (or Listener/) — this is the actual redirect logic; read it before trusting any assumption about matching semantics.Entity/Redirection.php — field names and defaults.Admin/RedirectionAdmin.php — which fields are editable and whether active is a toggle.DependencyInjection/Configuration.php — whether there's a configurable enabled flag or entity class override.Programmatic creation (bulk imports, data migrations). When you migrate a legacy site with 400 redirects, do not hand-enter them in Sonata. Write a Doctrine data migration or a console command:
use Runroom\RedirectionBundle\Entity\Redirection;
foreach ($csvRows as [$source, $destination, $code]) {
$r = new Redirection();
$r->setSource($source);
$r->setDestination($destination);
$r->setHttpCode($code); // verify setter name in the entity
$r->setActive(true); // verify setter name
$em->persist($r);
}
$em->flush();
Always re-read the entity after composer update — this package has near-zero adoption and no BC promise, so setter names are not something you should bake into muscle memory.
Trailing-slash and case normalization. The listener almost certainly matches $request->getPathInfo() as a raw string. That means /Old-Page/ and /old-page are two different rows. Before you trust the table, decide on a canonical form (lowercase, no trailing slash) and enforce it:
kernel.request listener with a lower priority than the bundle's listener that normalizes and re-dispatches. Be careful — this is where double-redirects come from.Ordering against Symfony routing. The redirect listener runs on kernel.request. If it runs before the router, redirects win over real routes (usually what you want for a stale URL). If it runs after, real routes shadow your redirects and you'll wonder why a redirect "doesn't work" — the route existed and the listener never fired. Check the listener priority in the bundle's service definition and, if needed, override it in your services.yaml:
services:
Runroom\RedirectionBundle\EventListener\RedirectionListener:
tags:
- { name: kernel.event_listener, event: kernel.request, priority: 100 }
Query string handling. A 301 to a fixed destination drops the original query string. If you have tracking params (?utm_*) or search pages, either accept the loss or write a custom listener that appends $request->getQueryString() to the destination. Do this in your own subclass rather than patching vendor code.
Cache the lookup table. The naive implementation hits the database on every single request, including for the 99% of requests that have no redirect. On a busy site this is a per-request query you cannot afford. Options, in order of effort:
RedirectionProvider of your own that loads all active rows into a PSR-6/PSR-16 cache (or a cache.app pool) with an invalidation hook on postPersist/postUpdate in Sonata.Testing. Two layers:
// Functional — the thing users experience
public function testRedirectsLegacyUrl(): void
{
$client = static::createClient();
$client->request('GET', '/old-landing-page');
$client->assertResponseRedirects('/new-landing-page', 301);
}
// Integration — assert the admin path can save and the kernel still fires
Seed a fixture Redirection row per test rather than relying on shared DB state; Sonata's admin form will otherwise leak state between tests in ways that are annoying to debug.
Integration with Sonata's other tools. If you're already using Sonata for pages, this bundle is the natural home for "depublished page still reachable" redirects. Consider a small postPersist listener on your page entity that creates a Redirection when a page is unpublished, so editors don't have to remember.
This is a subtree-split, READ-ONLY repository. The description literally says [READ-ONLY], and it's a split of Runroom/RunroomPackages. Issues and PRs opened here will be ignored. If you need a fix, you fork the monorepo, or you subclass. Plan for the latter.
One star, zero dependents, last-touched years ago (probably). Do not expect Symfony 6/7 compatibility to be current, PHP 8.2+ type safety, or a maintainer to answer your issue. Before adopting, run composer why-not and check the package's composer.json for the highest Symfony constraint it accepts. If you're on a recent Symfony, it may resolve only with --ignore-platform-reqs, which is a red flag, or it may genuinely not install. Treat the bundle as a starting point, not a dependency you can lean on.
The DB query on every request is the number-one performance trap. See the caching pattern above. If you're on a high-traffic site, measure the query count before and after — you will see it on every page view.
active flag vs deletion. Editors love to delete. But a deleted redirect means the old URL 404s again, which was the original problem. Train admins to set active = false instead, or add a soft-delete field. Consider a Sonata preRemove hook that refuses to delete and flips active instead.
301 is cached aggressively by browsers and CDNs. If an editor fat-fingers a destination with a 301, you cannot fix it for users who already hit it. Consider making the default 302 in your import, or at least warn editors in the admin form's help text: '301 is permanent — browsers and CDNs will cache it.' This single sentence prevents more support tickets than any code change.
No pattern matching, most likely. These bundles typically store a literal source string and compare it to getPathInfo() with ===. Do not assume /blog/* or ^/news/\d+$ will work — if you need wildcards, regex, or prefix rules, you write your own listener (or a sourceIsRegex column plus preg_match). Confirm by reading the listener before promising a stakeholder that "we can redirect all the old blog URLs."
Locale and hostname are not part of the key. If your site is multilingual (/en/..., /fr/...) or multi-domain, a redirect created in one locale applies to every locale unless the listener is locale-aware. Test with an Accept-Language header and with the locale-prefixed URL. If it's a problem, prefix source with the locale in your import routine and rely on exact matching.
Trailing slash drift. Symfony normalizes some URLs and your web server may normalize others. A redirect on /foo/ will not fire for /foo if the server strips the slash before PHP sees it (or vice versa). Test both forms manually and decide which one to store.
Debugging "the redirect doesn't fire": in order —
php bin/console debug:event-dispatcher kernel.request — is the listener registered at all?RouterListener.var_dump($request->getPathInfo()) in your own kernel.request listener placed at priority 1000 — does the path match what's in the DB exactly?active is 1 and not null./x) not a full URL, unless the entity explicitly supports absolute URLs — a relative destination will produce a malformed Location header.RedirectResponse being replaced by a later listener (e.g., a security firewall redirect to login) — if the user is anonymous, the firewall may win.Extension point: the listener is the place to hook. Subclass it, override the source-matching method (or the entire onKernelRequest), and register your subclass under the same service id. That's the only clean way to get regex matching, query-string passthrough, or locale scoping without forking. Keep the override small — the rest of the bundle is a fairly thin admin CRUD, and re-implementing it is not worth your time.
Lockfile discipline. Because this package is unmaintained, pin it: "runroom-packages/redirection-bundle": "x.y.*" and commit composer.lock. An unpinned minor bump that drops a Symfony constraint can break a Friday deploy, and there will be nobody upstream to help you roll it back.
Sonata coupling. The admin class requires Sonata Admin to be installed. If you're not running Sonata, you inherit a heavy transitive dependency just for a redirect table. In that case, skip the bundle, copy the entity and listener into src/ (both are under ~100 lines), and expose the CRUD through EasyAdmin or a custom controller. Given the zero-adoption status, this is often the more honest choice — you get the same feature with a codebase you can actually maintain.
How can I help you explore Laravel packages today?