A powerful and flexible Laravel package for managing HTTP redirects through a database-driven approach. Dynamically create, update, and track redirects without modifying code or redeploying your application.
- Database-driven redirects - Manage redirects dynamically without code changes
- Multiple matching strategies - HTTP, wildcard, and strict matching middleware
- Status code support - 301, 302, 307, 308 redirects
- Query string preservation - Automatically maintains query parameters
- Hit tracking - Monitor redirect usage with built-in analytics
- Event-driven automation - Automatically create redirects when URLs change
- SEO-friendly - Proper HTTP status codes and trailing slash handling
- Case sensitivity control - Configure case-sensitive or insensitive matching
- Trailing slash handling - Flexible trailing slash sensitivity options
- Protocol agnostic - Works with HTTP and HTTPS seamlessly
- Installation
- Configuration
- Usage
- Middleware
- Advanced Usage
- API Reference
- Testing
- Changelog
- Contributing
- Security
- Credits
- License
You can install the package via Composer:
composer require backstage/laravel-redirectsPublish and run the migrations:
php artisan vendor:publish --tag="laravel-redirects-migrations"
php artisan migrateThis will create a redirects table with the following structure:
| Column | Type | Description |
|---|---|---|
| ulid | ULID | Primary key |
| source | string | The source URL to redirect from |
| destination | string | The destination URL to redirect to |
| code | integer | HTTP status code (301, 302, 307, 308) |
| hits | integer | Number of times this redirect was triggered |
| created_at | timestamp | When the redirect was created |
| updated_at | timestamp | When the redirect was last modified |
Optionally, publish the configuration file:
php artisan vendor:publish --tag="laravel-redirects-config"The configuration file config/redirects.php provides extensive customization options:
return [
/*
* Available HTTP status codes for redirection.
* Uncomment additional codes as needed.
*/
'status_codes' => [
301 => 'Moved Permanently', // Permanent redirect, cached by browsers
302 => 'Found', // Temporary redirect, not cached
307 => 'Temporary Redirect', // Temporary, maintains HTTP method
308 => 'Permanent Redirect', // Permanent, maintains HTTP method
],
/*
* The model to use for managing redirects.
* Override this to use your own custom model.
*/
'model' => Backstage\Redirects\Laravel\Models\Redirect::class,
/*
* Default status code for new redirects.
* Can be overridden via REDIRECT_DEFAULT_STATUS_CODE env variable.
*/
'default_status_code' => env('REDIRECT_DEFAULT_STATUS_CODE', 301),
/*
* Case sensitivity for URL matching.
*
* false: /example and /Example are treated as the same
* true: /example and /Example are treated as different URLs
*/
'case_sensitive' => env('REDIRECT_CASE_SENSITIVE', false),
/*
* Trailing slash sensitivity for URL matching.
*
* false: /example and /example/ are treated as the same
* true: /example and /example/ are treated as different URLs
*/
'trailing_slash_sensitive' => env('REDIRECT_TRAILING_SLASH_SENSITIVE', false),
/*
* Add trailing slash to redirect destinations.
* Useful for maintaining URL consistency and SEO.
*/
'trailing_slash' => env('REDIRECT_WITH_TRAILING_SLASH', false),
/*
* Middleware stack for handling redirects.
* Order matters - first match wins.
*
* - HttpRedirects: Matches URLs with protocol/www variations
* - WildRedirects: Partial URL matching (contains)
* - StrictRedirects: Exact URL matching
*/
'middleware' => [
Backstage\Redirects\Laravel\Http\Middleware\HttpRedirects::class,
Backstage\Redirects\Laravel\Http\Middleware\WildRedirects::class,
Backstage\Redirects\Laravel\Http\Middleware\StrictRedirects::class,
],
];Add these to your .env file for environment-specific configuration:
REDIRECT_DEFAULT_STATUS_CODE=301
REDIRECT_CASE_SENSITIVE=false
REDIRECT_TRAILING_SLASH_SENSITIVE=false
REDIRECT_WITH_TRAILING_SLASH=falseuse Backstage\Redirects\Laravel\Models\Redirect;
Redirect::create([
'source' => '/old-page',
'destination' => '/new-page',
'code' => 301,
]);use Backstage\Redirects\Laravel\Models\Redirect;
// Permanent redirect (301)
Redirect::create([
'source' => '/old-blog-post',
'destination' => '/new-blog-post',
'code' => 301,
]);
// Temporary redirect (302)
Redirect::create([
'source' => '/maintenance',
'destination' => '/under-construction',
'code' => 302,
]);
// Wildcard redirect (matches any URL containing the source)
Redirect::create([
'source' => '/blog/category/',
'destination' => '/articles/',
'code' => 301,
]);php artisan tinkerRedirect::create([
'source' => 'example.com/old-url',
'destination' => 'example.com/new-url',
'code' => 301,
]);The package includes an event-listener system to automatically create redirects when URLs change. This is useful when updating slugs or moving content:
use Backstage\Redirects\Laravel\Events\UrlHasChanged;
// When a blog post URL changes
event(new UrlHasChanged(
oldUrl: 'https://example.com/old-slug',
newUrl: 'https://example.com/new-slug',
code: 301
));This automatically creates a redirect in the database:
// Created automatically by the listener
Redirect::create([
'source' => 'https://example.com/old-slug',
'destination' => 'https://example.com/new-slug',
'code' => 301,
]);Integration Example with Eloquent Models:
use Backstage\Redirects\Laravel\Events\UrlHasChanged;
use Illuminate\Database\Eloquent\Model;
class BlogPost extends Model
{
protected static function booted()
{
static::updating(function ($post) {
if ($post->isDirty('slug')) {
$oldUrl = route('blog.show', $post->getOriginal('slug'));
$newUrl = route('blog.show', $post->slug);
event(new UrlHasChanged($oldUrl, $newUrl, 301));
}
});
}
}The package automatically preserves query strings from the source URL and appends them to the destination:
Redirect::create([
'source' => '/old-page',
'destination' => '/new-page',
'code' => 301,
]);When a user visits:
/old-page?utm_source=email&utm_campaign=newsletter
They are redirected to:
/new-page?utm_source=email&utm_campaign=newsletter
If the destination already has query parameters:
Redirect::create([
'source' => '/old-page',
'destination' => '/new-page?foo=bar',
'code' => 301,
]);Visiting /old-page?baz=qux redirects to:
/new-page?foo=bar&baz=qux
Every time a redirect is triggered, the hits counter increments automatically:
$redirect = Redirect::where('source', '/old-page')->first();
echo $redirect->hits; // Number of times this redirect was usedUse this data for analytics and monitoring:
// Most used redirects
$popular = Redirect::orderBy('hits', 'desc')->take(10)->get();
// Recently created redirects
$recent = Redirect::latest()->take(10)->get();
// Unused redirects (candidates for removal)
$unused = Redirect::where('hits', 0)->get();The package includes three middleware classes, each with different matching strategies. They run in the order defined in config/redirects.php.
Matches URLs with protocol and www variations normalized:
Redirect::create([
'source' => 'example.com/page',
'destination' => 'example.com/new-page',
'code' => 301,
]);This matches all of these URLs:
http://example.com/pagehttps://example.com/pagehttp://www.example.com/pagehttps://www.example.com/page
Performs partial URL matching using contains():
Redirect::create([
'source' => '/blog/',
'destination' => '/articles/',
'code' => 301,
]);This matches:
/blog/post-1→/articles//blog/category/tech→/articles//old-blog/archive→/articles/
Exact URL matching without query strings:
Redirect::create([
'source' => 'example.com/exact-page',
'destination' => 'example.com/new-exact-page',
'code' => 301,
]);This only matches:
http://example.com/exact-pagehttps://example.com/exact-pagehttp://www.example.com/exact-page
But NOT:
example.com/exact-page/sub-pageexample.com/other-exact-page
The middleware runs in the order defined in your config. First match wins:
'middleware' => [
// 1. Try HTTP matching first (protocol/www normalized)
Backstage\Redirects\Laravel\Http\Middleware\HttpRedirects::class,
// 2. Then wildcard matching
Backstage\Redirects\Laravel\Http\Middleware\WildRedirects::class,
// 3. Finally exact matching
Backstage\Redirects\Laravel\Http\Middleware\StrictRedirects::class,
],You can reorder or remove middleware as needed. For example, to only use exact matching:
'middleware' => [
Backstage\Redirects\Laravel\Http\Middleware\StrictRedirects::class,
],Create your own model extending the base Redirect model:
namespace App\Models;
use Backstage\Redirects\Laravel\Models\Redirect as BaseRedirect;
class Redirect extends BaseRedirect
{
// Add custom scopes
public function scopeActive($query)
{
return $query->where('active', true);
}
// Add relationships
public function user()
{
return $this->belongsTo(User::class);
}
// Override redirect logic
public function redirect(Request $request): ?RedirectResponse
{
// Custom logic before redirect
\Log::info("Redirecting from {$this->source} to {$this->destination}");
return parent::redirect($request);
}
}Update your config:
'model' => App\Models\Redirect::class,Bulk Creation:
$redirects = [
['source' => '/old-1', 'destination' => '/new-1', 'code' => 301],
['source' => '/old-2', 'destination' => '/new-2', 'code' => 301],
['source' => '/old-3', 'destination' => '/new-3', 'code' => 301],
];
foreach ($redirects as $redirect) {
Redirect::create($redirect);
}Import from CSV:
use Illuminate\Support\Facades\Storage;
use League\Csv\Reader;
$csv = Reader::createFromPath(Storage::path('redirects.csv'));
$csv->setHeaderOffset(0);
foreach ($csv->getRecords() as $record) {
Redirect::create([
'source' => $record['source'],
'destination' => $record['destination'],
'code' => $record['code'] ?? 301,
]);
}Conditional Redirects:
// Only redirect if destination exists
if (Route::has('new-route')) {
Redirect::create([
'source' => '/old-route',
'destination' => route('new-route'),
'code' => 301,
]);
}Redirect Chains (avoid these):
// BAD: Creates a redirect chain
// /page-1 → /page-2 → /page-3
Redirect::create(['source' => '/page-1', 'destination' => '/page-2', 'code' => 301]);
Redirect::create(['source' => '/page-2', 'destination' => '/page-3', 'code' => 301]);
// GOOD: Direct redirect
Redirect::create(['source' => '/page-1', 'destination' => '/page-3', 'code' => 301]);Properties:
ulid(string) - Primary keysource(string) - Source URLdestination(string) - Destination URLcode(int) - HTTP status codehits(int) - Number of redirects performedcreated_at(timestamp)updated_at(timestamp)
Methods:
// Perform the redirect
public function redirect(Request $request): ?RedirectResponse
// Increment hits counter (called automatically)
public function increment('hits'): voidUrlHasChanged Event:
use Backstage\Redirects\Laravel\Events\UrlHasChanged;
event(new UrlHasChanged(
oldUrl: 'https://example.com/old',
newUrl: 'https://example.com/new',
code: 301 // Optional, defaults to 301
));Properties:
oldUrl(string) - The old URLnewUrl(string) - The new URLcode(int) - HTTP status code (default: 301)
RedirectOldUrlToNewUrl Listener:
Automatically creates a redirect when UrlHasChanged event is dispatched.
Understanding when to use each status code:
| Code | Name | Use Case | Cached by Browsers |
|---|---|---|---|
| 301 | Moved Permanently | Permanent content relocation, old URL will never be used again | Yes |
| 302 | Found | Temporary redirect, old URL may be used again | No |
| 307 | Temporary Redirect | Temporary redirect that preserves HTTP method (POST stays POST) | No |
| 308 | Permanent Redirect | Permanent redirect that preserves HTTP method (POST stays POST) | Yes |
Recommendations:
- Use 301 for most permanent redirects (blog posts, pages, renamed resources)
- Use 302 for temporary situations (maintenance pages, A/B testing)
- Use 307 when redirecting form submissions temporarily
- Use 308 when permanently moving an API endpoint that receives POST/PUT/DELETE requests
Run the test suite:
composer testRun tests with coverage:
composer test-coverageRun static analysis:
composer analyseFix code style:
composer formatWriting Tests:
use Backstage\Redirects\Laravel\Models\Redirect;
it('redirects old URL to new URL', function () {
Redirect::create([
'source' => '/old',
'destination' => '/new',
'code' => 301,
]);
$response = $this->get('/old');
$response->assertRedirect('/new');
$response->assertStatus(301);
});
it('preserves query strings', function () {
Redirect::create([
'source' => '/old',
'destination' => '/new',
'code' => 301,
]);
$response = $this->get('/old?foo=bar');
$response->assertRedirect('/new?foo=bar');
});
it('increments hits counter', function () {
$redirect = Redirect::create([
'source' => '/old',
'destination' => '/new',
'code' => 301,
]);
expect($redirect->hits)->toBe(0);
$this->get('/old');
expect($redirect->fresh()->hits)->toBe(1);
});Please see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details on how to contribute to this package.
Please review our security policy on how to report security vulnerabilities.
Security Considerations:
- Validate redirect destinations to prevent open redirects
- Sanitize user input when creating redirects programmatically
- Monitor for redirect loops and chains
- Implement rate limiting to prevent abuse
- Use HTTPS for all redirect destinations when possible
- Mark van Eijk - Creator and maintainer
- All Contributors
The MIT License (MIT). Please see License File for more information.
Built with by Backstage CMS