funstaff/redirect-bundle
Funstaff RedirectBundle is a Symfony bundle for managing URL redirects in your application. It helps centralize redirect rules, handle legacy links, and keep navigation working smoothly with minimal configuration. Ideal for maintaining SEO-friendly routes when URLs change.
Read this first: this is a Symfony bundle, not a Laravel package. funstaff/redirect-bundle ships a Symfony Bundle class, a Doctrine entity/repository, and a kernel.request event listener. It will not install cleanly into a Laravel app and there is no service provider, facade, or Eloquent model. If your team is Laravel-only, treat this assessment as a design reference and plan to reimplement the listener as middleware (or reach for spatie/laravel-missing-page-redirector). The rest of this section assumes a Symfony host app.
Minimal path to a working redirect:
composer require funstaff/redirect-bundle — the package is old and low-traffic (3 stars, 0 dependents), so expect an unconstrained or stale composer.json. Pin the version explicitly and be ready to add "minimum-stability": "dev" or a repositories entry if it isn't on Packagist under the expected name. Verify with composer show funstaff/redirect-bundle before going further.config/bundles.php:
Funstaff\RedirectBundle\FunstaffRedirectBundle::class => ['all' => true],
(Legacy apps: add it to AppKernel::registerBundles().)Redirect entity holding at least a source path and a destination URL. Run php bin/console doctrine:schema:validate and then generate a migration — do not use doctrine:schema:update --force in anything shared.DependencyInjection/Configuration.php and Resources/config/services.* before writing any code. That is where the real API lives: the listener's tag and priority, the entity class, and any table-name or status-code options. If a funstaff_redirect: config key exists, that is your extension surface./old-landing-page) pointing at a live URL, then curl -I https://your-app.test/old-landing-page and look for a 301/302 and a Location header.First real use case: a site relaunch or URL-scheme cleanup where you have a spreadsheet of a few hundred legacy paths. The bundle's value is that marketing/ops can add a redirect row without a deploy, and the listener short-circuits before the router has to 404.
Seeding redirects as data, not code. The most valuable property of this package is that redirects live in the database. Keep it that way. Load the initial map from a Doctrine fixture or a doctrine:migrations data migration so the map is versioned alongside the deploy that breaks the old URLs. A fixture run only in dev is a trap; use a migration or an idempotent console command.
Bulk import command. Write one. A app:redirects:import path/to/map.csv command that parses source/destination pairs and upserts via the repository pays for itself the first time you migrate 800 URLs. Use EntityManager::flush() in batches of ~100 and make the command re-runnable (look up by source path before insert).
Listener ordering is the whole game. The redirect listener hooks kernel.request. Whether it should sit above or below Symfony's RouterListener depends on intent: above (higher priority) means DB redirects beat live routes, which is dangerous — you can shadow working pages. Below means it only fires for paths the router has already failed, which is the safer default but requires the listener to distinguish "no route matched" from "route matched but controller 404s." Read the listener source and check its getSubscribedEvents() priority against RouterListener (priority 32) before trusting it. If it overrides live routes, lower its priority explicitly via the service definition rather than fighting it in config.
Handle multi-hop chains at write time, not read time. If /a → /b and /b → /c both exist, naive lookup produces two round trips and a worse UX. Normalize on save: when creating /a, resolve its destination through the existing map until it terminates, then store the final target. Also refuse to create a row whose destination equals an existing source that eventually points back at itself — cheap loop detection that saves an outage.
Trailing slashes, query strings, and case. The listener matches the request path. Decide up front whether /old/ and /old are distinct keys and normalize in a single place (a normalizePath() helper used by both the importer and the listener). Query strings are almost never part of the stored key — confirm the listener strips them, because /old?utm_source=x arriving as a miss is a classic silent failure.
Cache the lookup. One DB query per 404-bound request is fine; one per request is not, and it will show up in the profiler as a query on every page. Wrap the repository call in a small in-memory (per-request) or PSR-6 cache keyed by normalized path. Invalidate on write — the simplest correct approach is a cache pool with a short TTL plus explicit invalidation in the admin controller.
Admin integration. The entity is plain Doctrine, so EasyAdmin or SonataAdmin will surface it in minutes. Expose only source, destination, and an active flag to editors; keep status code and hit counters to developers. A hitCount/lastUsedAt column populated by the listener turns the table into a decommissioning report six months later.
Status code choice. Default to 302 while a redirect map is new and being verified, then promote the stable ones to 301. Never bulk-promote before you've clicked through the list.
Testing. Use WebTestCase with KernelBrowser and assert on getResponse()->isRedirect($expected) plus the Location header. Cover: exact match, missing path (still 404), disabled row, loop rejection, and the trailing-slash variant.
Adoption risk is the headline. 3 stars and 0 dependents means effectively no community review, no recent-version compatibility guarantees, and no one to ask when the listener behaves oddly. composer outdated it, run it through composer audit, read every PHP file (there aren't many), and vendor-fork it if you ship it. Do not put this in a client project without that review.
It is not maintained against current Symfony. Expect deprecations from ContainerAware, XML/YAML service definitions, or annotation-based Doctrine mapping. Modern Symfony (6/7) removed several of these. Budget a half-day to convert mapping to attributes and services to PHP config if you must run it.
A mis-ordered listener causes the worst bug class: shadowed routes. If the listener fires before routing and matches a path that is also a live route, a well-meaning editor's redirect silently kills a page. Symptom: "the homepage redirects to itself." Mitigation: have the listener check $request->attributes->get('_route') and bail when a route already matched, and log every hit with source→destination so you can reconstruct incidents.
301s are cached by browsers, permanently. A wrong 301 propagated to a customer's browser is unfixable from your side. Test in 302, verify with curl and with a private window, then promote. Consider serving redirects with Cache-Control: no-cache while the map is under active churn.
Locale and prefix handling. /{_locale}/old-page will not match a stored key of /old-page. If the app is localized, store prefixed keys or strip the locale before lookup — pick one and document it, because this is the single most common reason "the redirect just doesn't fire" in a multi-language site.
Loops produce ERR_TOO_MANY_REDIRECTS, not a 500. Nothing in the bundle will stop an A→B, B→A pair from being inserted. Add a validation constraint on the entity that walks the chain with a depth cap (say 5) and throws on cycle. Also cap resolution depth at read time as a belt-and-braces guard.
Debugging a redirect that won't fire, in order: (1) is the bundle registered and is the service tagged kernel.event_listener? check debug:event-dispatcher kernel.request; (2) is the row enabled/active? (3) does the exact stored string match $request->getPathInfo() byte for byte? (4) is a higher-priority listener or a web-server-level rewrite intercepting first — check nginx/Apache config and any CDN rules; (5) is the response being cached by a reverse proxy so you're seeing a stale answer? Add a temporary dump()/log at the top of the listener to confirm it's even invoked.
Extension points, ranked by usefulness: decorate or subclass the repository for lookup caching; add a Doctrine lifecycle callback for loop validation; override the listener service in your app's services.yaml to change priority or add logging; wrap the redirect response to attach analytics headers. Avoid editing the vendor directory — the package is unlikely to be patched upstream and you'll lose the change on the next composer update.
Have an exit plan. The schema is trivial (source, destination, status, enabled) and the listener is a few dozen lines. If the package blocks a Symfony upgrade, reimplementing it as an app-level subscriber is a one-day job and removes a dependency with no maintainer. Write the port before you need it, not after.
How can I help you explore Laravel packages today?