zenstruck/redirect-bundle
Symfony bundle for managing HTTP redirects directly in your app instead of web server config. Define source paths, targets, and status codes, then let the bundle match incoming requests and issue the redirects automatically.
composer require zenstruck/redirect-bundle gives you three things worth knowing about: a Redirect entity (source path → destination path, plus a timestamp for when it was last hit), a RedirectManager service that does the lookup and hands back a RedirectResponse, and a Doctrine mapping you point at your own entity class.
First, decide what "installing" means for you. This is a Symfony bundle. In a Symfony app, Flex registers it, you create an entity extending Zenstruck\RedirectBundle\Model\Redirect (or use the shipped concrete one), set zenstruck_redirect.redirect_class, and run a migration. In a Laravel app there is no bundle kernel to register — you get the PHP classes and are on your own for wiring, plus a second ORM (doctrine/orm) next to Eloquent. That's usually not worth it; the pattern (one table, one manager, one early-request check) is worth copying onto Eloquent directly.
Where to look first: RedirectManager::findAndUpdate(), RedirectManager::getRedirectResponse(), and the Model\Redirect base class. Everything else is config and admin sugar.
First use case: intercept a request before routing, look up the path, and return a 301 if there's a match. In Symfony that's a kernel.request listener; in Laravel a global middleware registered ahead of the router. Because Laravel's Illuminate\Http\Request extends Symfony\Component\HttpFoundation\Request, the manager's request-based methods accept it as-is — that's the one piece of real interop here.
Then seed 5–10 known-dead legacy URLs, curl them, and confirm you get a 301 with the right Location and that the hit timestamp moved.
Intercept early, return immediately. The redirect check belongs before routing, not in a controller or a 404 handler — you want to skip the router, middleware stack, and any auth entirely for a redirect. Guard it to GET/HEAD and to non-asset, non-API prefixes so you're not doing a query per favicon.
Canonicalize before lookup. Pick one stored form (leading slash, no trailing slash, lowercase, query string stripped) and normalize the incoming path to match before calling the manager. Every "the redirect exists but doesn't fire" bug starts here.
Bulk import as a command, not a loop in a seeder. A console command that reads a CSV/old sitemap and upserts is the day-two need. Batch the flushes — a per-row flush on a 5,000-URL import is minutes of wall time. Make the command idempotent so re-running after a partial import is safe.
Use the hit timestamp as your analytics. It's the cheapest observability you'll get: which redirects are hot (keep), which have never been hit since import (delete candidates), which destinations are being hammered and deserve a real page again. A weekly query over that column is the whole reporting story.
Promote 404s into redirects. The bundle models "not found" entries alongside redirects so that a 404 becomes a row you can later fill in a destination for. If you build this on Eloquent, keep that split: log the miss cheaply, create the redirect deliberately. Don't auto-redirect on miss.
Cache the map, not the request. The table is small and read-mostly. Either cache the whole path => destination map and rebuild on write, or Cache::remember per path with a version-key bump on any write. Skipping this means a DB round-trip on every page view.
Chain with a hop limit. If a destination is itself a source, resolve it — but cap at 1–2 hops and bail on cycles. Better: forbid saving a destination that matches an existing source.
Multi-domain: if one app serves several hostnames, either store host-qualified sources or add a site/scope column. Retrofitting this later is a data migration.
Testing: a functional test asserting 301 + Location for a seeded row, and a unit test of the manager against an in-memory entity. The response-building method is the only part with interesting logic.
zenstruck/foundry is a fixture library). If you don't test against the bundle's fixtures, it cannot affect you.doctrine/orm into a Laravel app to store one table of two strings is a bad trade — reimplement on Eloquent in ~50 lines and keep the design.source is almost certainly unique-indexed. Duplicate seeds and concurrent inserts will throw. Use upsert, or catch the constraint violation and move on./blog/* behavior you're either adding a matcher yourself or storing every URL individually. Confirm this before you promise a URL structure to anyone.redirect_class, decorate/replace RedirectManager, or bypass matching entirely. The entity + manager split is clean enough that replacing the lookup is a contained change.How can I help you explore Laravel packages today?