eo/redirect-bundle
EoRedirectBundle is a Symfony 2.1+ bundle for handling redirects, installable via Composer and enabled in AppKernel. It is released under the MIT license, with issues and feature requests tracked on GitHub.
Read this first: eo/redirect-bundle is a Symfony 2.1+ bundle, not a Laravel package. There is no service provider, no config/redirect.php, no publishable migration, and no middleware class registered through bootstrap/app.php. composer require eo/redirect-bundle will drop files in vendor/eo and nothing will happen — no auto-discovery, no routes, no container bindings. Treat it as a reference implementation to read, not a dependency to install.
Where to look first (spend 20 minutes, not a day):
vendor/eo/redirect-bundle/Eo/RedirectBundle/EoRedirectBundle.php — confirms it's a Bundle subclass, i.e. dead end for Laravel.DependencyInjection/ — what services it wires, whether the redirect store is Doctrine-backed or YAML-configured.EventListener/ or EventListener/-equivalent — the actual request interception logic; this is the only part worth porting.Entity/ — the schema shape (source, target, status code), which is the useful output of the whole exercise.Resources/config/ — service definitions and any routing/config defaults.Your first use case is almost certainly legacy URL preservation during a replatform: an old marketing site or a rebranded path structure that must 301 to new URLs without leaking link equity into 404s. Build it natively. Minimum viable version:
// database/migrations/xxxx_create_redirects_table.php
Schema::create('redirects', function (Blueprint $table) {
$table->id();
$table->string('source_path')->unique(); // normalized, leading slash, no query
$table->string('target_url'); // relative path or allowlisted absolute
$table->unsignedSmallInteger('status_code')->default(301);
$table->boolean('is_active')->default(true);
$table->unsignedBigInteger('hit_count')->default(0);
$table->timestamp('last_hit_at')->nullable();
$table->timestamps();
});
// app/Http/Middleware/RedirectLegacyPaths.php
public function handle(Request $request, Closure $next)
{
if (! $request->isMethodCacheable()) {
return $next($request);
}
$path = '/'.ltrim($request->path(), '/');
$rule = Redirect::map()[$path] ?? null;
if ($rule) {
$target = $rule['target'].($request->getQueryString() ? '?'.$request->getQueryString() : '');
Redirect::where('source_path', $path)->toBase()->increment('hit_count'); // or after-response
return redirect()->to($target, $rule['status'], [], $request->isMethod('HEAD') ? null : null);
}
return $next($request);
}
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->prepend(RedirectLegacyPaths::class); // must run before routing
})
Only if you genuinely still operate a Symfony 2.x service: composer require eo/redirect-bundle:dev-master, pin to a commit SHA, and register new Eo\RedirectBundle\EoRedirectBundle() in AppKernel::registerBundles(). Note that Symfony 2.x has been EOL since 2019 — that registration line is a migration backlog item, not an architecture choice.
Global middleware, prepended. Register the redirect check as global middleware and prepend() it. Global middleware runs before route resolution, which is exactly what you need for paths that no longer have a route. If you register it as a route middleware or after SubstituteBindings, unknown legacy paths will 404 before your lookup ever fires.
Cache the whole map, invalidate on write. A per-request SELECT on redirects is a fixed latency tax on every page view. Load the map once into Redis/APCu and refresh on mutation:
public static function map(): array
{
return Cache::rememberForever('redirects.map', fn () => static::query()
->where('is_active', true)
->get(['source_path', 'target_url', 'status_code'])
->mapWithKeys(fn ($r) => [$r->source_path => ['target' => $r->target_url, 'status' => $r->status_code]])
->all());
}
protected static function booted(): void
{
static::saved(fn () => Cache::forget('redirects.map'));
static::deleted(fn () => Cache::forget('redirects.map'));
}
Prefer 404-triggered resolution for large rule sets. Two viable patterns:
Most Laravel apps want pre-routing for known legacy paths and 404 capture simultaneously, feeding unmatched 404s into a redirect_candidates table for editorial review.
301 vs 302 decisions belong in the database, not the code. 301 for permanent moves (replatform, slug change, domain consolidation); 302/307 for temporary campaign or A/B routing. Bake status_code into the row so SEO changes don't need a deploy.
Flatten chains at write time. If an editor creates A→B and B→C, silently rewrite A→C on save. Enforce a hop limit (3) at read time as a belt-and-braces guard, and reject self-referential rows in a form request validation rule.
Normalize before you look up. Store and query the same canonical form: leading slash, lowercase, no trailing slash, no query string, no fragment. Do the normalization in one place (a Redirect::normalizePath() helper used by both the middleware and the admin form) or you will get "redirect exists but doesn't fire" bugs indefinitely.
Record hits off the request path. increment('hit_count') on every redirect is a write in the critical path. Use dispatchAfterResponse(), a queued job, or a terminable middleware so the browser is already gone before the counter updates.
Admin surface beats config files. Wire a Filament/Nova/custom CRUD resource behind RedirectPolicy — content editors own the redirect table, engineers own loop detection and validation. Ship an artisan redirects:import {csv} command for bulk migrations with a --dry-run that reports duplicates, self-redirects, and unresolvable chains.
Test in feature tests, not unit tests. Redirects are request/response behaviour:
it('301s a legacy path and preserves the query string', function () {
Redirect::factory()->create(['source_path' => '/old-page', 'target_url' => '/new-page', 'status_code' => 301]);
$this->get('/old-page?utm_source=x')
->assertStatus(301)
->assertRedirect('/new-page?utm_source=x');
});
Shadow-run before cutover. Diff your intended redirect map against a week of production access logs (404s and known-legacy paths). This catches paths you forgot to normalize and paths you normalized wrong.
Hard stop: do not install this in a Laravel app. There is no adapter path. The bundle's contract is AppKernel + DI extension + Symfony event dispatcher. Injecting Symfony's dispatcher into Laravel's container to satisfy one bundle is a maintenance liability that will outlive the developer who wrote it.
dev-master is the only install target — there are no tags. Every composer update can pull different code. If a legacy Symfony service must keep it, pin to a commit SHA in composer.json and commit composer.lock. Do not accept dev-master in any code review.
Open redirect is the security issue. A redirect table that stores absolute URLs from user or editor input is a phishing vector. Validate on write and read: accept only relative paths (^/[^/]) or hosts in an explicit allowlist. Never redirect()->to($request->input('next')) — that's the same bug in a different costume.
Browser caching makes 301s effectively permanent. Chrome caches them aggressively; if you ship a bad 301 you cannot fix it for users who already hit it. Ship new rules as 302 while validating in staging, then flip to 301 via a data update once the target is confirmed.
Middleware ordering is the whole game. If your redirect check runs after StartSession, VerifyCsrfToken, or auth, users get bounced to /login before your rule for /old-page is ever consulted. prepend() it and add a test that an unauthenticated request to a legacy path still 301s.
Skip the lookup for non-content traffic. Cheap early returns on /admin, /api, /livewire, /build, /storage, /.well-known, and health-check paths save you a cache roundtrip on the majority of requests in a real app.
Never redirect non-idempotent methods. Scope to GET/HEAD. A 301 on a POST silently converts it to GET and drops the body — a classic source of "checkout form lost my data" bugs. If you must preserve method semantics, use 308.
Cache invalidation across app servers. Cache::forget on an array or file driver only invalidates the local node. Use Redis/Memcached in multi-node deployments, or a versioned cache key (redirects.map.v{n}) bumped on write.
Trailing slashes and case are separate rows unless you normalize. /Old-Page/, /old-page, and /old-page/ all need to collapse to one key. Do it in a single normalize function, and add a unique index on source_path so you can't accidentally create duplicates through the admin panel.
Loop detection at the data layer, not the middleware. A middleware that detects a loop can only abort — it can't stop an editor creating one. Validate on save: reject source === target, reject A→B when B→A already exists, and walk the chain up to N hops.
Hit counters drift under concurrency. increment() is atomic in SQL and fine; read-modify-write via Eloquent ($r->hit_count++; $r->save()) is not, and will lose counts under load.
Reading the bundle's source is fine — MIT licensed. You may copy patterns and even code with the license notice retained, but rewriting 150 lines idiomatically in Laravel will take less time than adapting them and leaves you with something you can actually test, monitor, and extend.
Observability is not optional for this feature. Redirect bugs are silent ranking damage, not exceptions. Log every redirect hit (source, target, status, referer) at debug level, alert on redirect loop detection, and track the 404 rate as a first-class metric across any URL migration window.
If the requirement is really "hundreds of rules with editorial workflow, bulk import, and audit trails" — stop and reach for a purpose-built tool (or build the CRUD properly) rather than growing a 40-line middleware into a content management system by accident.
How can I help you explore Laravel packages today?