Download the PHP package asignua/filament-redirects without Composer
On this page you can find all versions of the php package asignua/filament-redirects. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Download asignua/filament-redirects
More information about asignua/filament-redirects
Files in asignua/filament-redirects
Package filament-redirects
Short Description A redirect manager and a 404 log for Filament 5 panels. The log aggregates the 404s your visitors and crawlers hit by path, with a referrer sample and client type, and drops scanner noise; one click turns a row into a redirect. Redirects are SEO-safe: they fire only on a real 404, are always one hop (chains are collapsed on save, loops refused), handle trailing slashes and HEAD, support multilingual paths, and are cached.
License MIT
Homepage https://github.com/asignua/filament-redirects
Informations about the package filament-redirects
Filament Redirects
A redirect manager and a 404 log for Filament panels, with one button that turns a dead URL from the log into a redirect.
The best-known Filament 5 redirect plugin has a few thousand downloads and none of the existing ones combines a 404 log with a one-click redirect. Redirect plugins stop at the table of rules. This one also watches what your visitors (and Googlebot) actually hit,
aggregates it by path with a referrer sample and the kind of client, drops the scanner noise, and lets you answer a
row with a redirect in one click. And the redirects themselves are the kind an SEO audit will not flag: they only
fire on a real 404, they are always one hop (chains are collapsed when you save, loops are refused), a slashed
/old/ reaches the target of /old in a single hop, and HEAD is served like GET.
- Screenshots
- Requirements
- Installation
- Quick start
- Putting the middleware on your site — read this one
- How paths are stored
- The 404 log
- Multilingual sites
- Creating redirects from code
- Hooks of the registry
- Authorization
- Commands and schedule
- Configuration
- Gotchas
- Translations
- AI agents
- Testing
Screenshots
The redirects list - one hop, any status code, a hit counter per redirect:

The 404 log - what visitors and Googlebot actually hit, with a referrer sample and the kind of client:

Create redirect on a row opens a modal prefilled by Redirects::suggestUsing(); the row leaves the log and the old URL redirects at once:

Requirements
- PHP 8.3+
- Laravel 12 or 13
- Filament 5
Installation
Optionally publish the config:
The package ships no CSS or JavaScript (it is built from Filament's own components), so there is nothing to
filament:assets.
Register the plugin in your panel provider:
[!WARNING] Restrict who can manage redirects. Without
authorize()(or aredirects.managegate) every user who can enter the panel can create a redirect from any path of your site to any external URL - an author or editor account is then enough to turn your domain into a phishing redirector. See Authorization.
Then put the middleware on your site - the next sections explain why the package cannot do that for you.
Quick start
An ordinary Laravel app: append the middleware globally in bootstrap/app.php.
Add a redirect in the Redirects resource - or just visit a dead URL on your site, open 404 log and click Create redirect on its row.
Putting the middleware on your site
Redirects::middleware() returns [RedirectTrailingSlash::class, RedirectFallbackMiddleware::class].
RedirectFallbackMiddlewareis an after middleware: it looks nothing up until the request has already produced a 404, so a live page is never touched.RedirectTrailingSlashacts before the route, and only on a slashedGET/HEADaddress (/old/): it looks/oldup in the cached map (and, when a row exists, asksRedirects::resolvesUsing()whether a live page sits there) and answers with the target in one hop, short-circuiting the route. Unslashed requests pass through untouched.
Both send a relative Location for an internal target, so a forged Host / X-Forwarded-Host header never ends
up in a cached permanent redirect.
For a site that serves everything through one catch-all route (a CMS front), put it on that route:
The trap that is the reason the package registers nothing by itself: a package cannot push a global middleware
(or one into the web group) from its own boot(). The HTTP kernel is built in public/index.php before the service
providers boot, so afterResolving(Kernel::class)->pushMiddleware(...) and pushMiddlewareToGroup(...) silently do
nothing in a real request - while appearing to work in tests, which resolve the kernel later. Route middleware is
resolved at dispatch, and bootstrap/app.php is read before the kernel exists; those two are reliable.
You do not need a locale middleware in front of them: both parse the language from the path themselves.
How paths are stored
old_path and to_path hold the shape of a request path after the language prefix is split off:
| Value | Stored as | Served as (default language / en) |
|---|---|---|
| A page | shop/item |
/shop/item / /en/shop/item |
| The home page | / |
/ / /en |
| An external site | https://other.site/x (the whole URL) |
https://other.site/x |
| Gone | an empty string, with code 404 | the site's own error page with redirects.gone_status (410) |
Everything is normalised on save, in one place (RedirectRepository), so pasting https://site.test/en/shop/item/
into a field of an en row stores shop/item: our own domain (app.url, plus anything you add with
Redirects::ownUrlsUsing(), each with its www. twin and port) and the row's own language prefix are stripped, a
foreign domain is kept. Paths are stored percent-decoded (привіт, not %D0%BF...) and matched against the decoded
request path, so a redirect typed in any alphabet fires for the encoded URL a browser sends. The leading slash exists only in the form - the field shows https://site.test + /shop as
an affix, so a redirect to the home page is typed as / (an empty target means Gone, not home).
The form refuses spaces, a query string (?) or fragment (#) in a source (only the path is matched), a foreign
host as a source, the home page as a source (it never 404s), a duplicate source in
the same language, a source that starts with another language's prefix (it could never match), a target equal to the
source or leading back to it, and a broken URL as a target.
The 404 log
The fallback middleware writes every unmatched GET that has no redirect into the log: one row per
(path, language) with a hit counter, first/last seen, a referrer sample and the User-Agent family
(Googlebot, Chrome, curl... - the full string is deliberately not stored).
- Create redirect (row action) opens a modal for the target;
Redirects::suggestUsing()can prefill it. The row leaves the log and the redirect counts the hits from then on. A loop is refused on the target field. - Create redirects (bulk action) sends many paths to one target; rows that would loop stay in the log and are reported. The target is typed relative to one language, so the selected rows must be of one language. "Gone" needs no target.
- Recheck closes rows that now have a redirect - and, with
Redirects::resolvesUsing(), rows whose path now shows a page. The panel button checks the 2000 most recently seen rows (one web request);redirects:recheckwalks the whole log. Prune old entries removes rows by age. - Filters: language, bots / no bots, with / without a referrer. A
Goneredirect and a matched redirect are deliberate decisions and are never logged.
Noise never reaches the table (filament-redirects.not_found):
and for anything the config cannot say: Redirects::ignoreUsing(fn (Request $request, string $language, string $path): bool => ...).
Paths longer than the column (191) are skipped, not truncated - truncated ones would merge different URLs.
A failed log write never breaks the response.
Multilingual sites
A single-language site needs no setup. Otherwise tell the registry how your URLs map to languages:
- The row has a
languagecolumn andunique(old_path, language); the form shows the language select only when there is more than one language. - A row's own prefix is stripped from what you type; another language's prefix is kept in a target (
en/shopis a valid target of aukrow) and refused in a source of the unprefixed language. - A target in another language. On a row of a prefixed language the first segment of a target that is ANOTHER prefixed
language is served in that language (
de/aktionon anenrow ->/de/aktion, not/en/de/aktion). To reach a page of the unprefixed language from a prefixed row, paste its whole address (https://site.test/abouton anenrow): it is kept whole and served from the site root. Chains that cross languages are not compacted (compaction works inside one language). - For a CMS that keeps languages in its own config, pass a closure - it is resolved at the moment of use:
Redirects::useLocaleUrls(fn (): LocaleUrls => new PrefixedLocaleUrls(...)). - For any other URL scheme implement
Asignua\FilamentRedirects\Contracts\LocaleUrls(parse,url,prefix,default,all,unprefixed) - for example, a CMS that resolves URLs through its own link table.
Creating redirects from code
The Redirects::create() API is the door for anything that is not the panel - a listener that redirects the old slug
when a record is renamed, a seeder, a migration:
It normalises, rejects a loop, a duplicate source or a source with ?/# (ValidationException), collapses chains
in one transaction and flushes the cache. Never write the model
directly: it has $guarded = ['*'], and a field that the repository does not assign is silently not saved. If you
must update the table with raw SQL, call Redirects::flushCache() afterwards.
The optional entity morph (entity_type, entity_id) records which record a redirect leads to; give it explicitly,
or let Redirects::entityUsing(fn (string $language, string $path): ?Model => ...) find it from the target.
Hooks of the registry
Everything that is a closure lives on Asignua\FilamentRedirects\Redirects (set it in AppServiceProvider); each
hook has a neutral default.
| Hook | Used for |
|---|---|
locales() / useLocaleUrls() |
the language scheme (above) |
ownUrlsUsing(fn (): array) |
the hosts that count as "this site" (default app.url); the first is the base URL |
resolvesUsing(fn (string $language, string $path): bool) |
"does an anonymous visitor get a page here?" - used by the recheck and the trailing-slash middleware |
entityUsing(fn (string $language, string $path): ?Model) |
ties a new redirect to the record its target leads to |
suggestUsing(fn (string $language, string $path): ?string) |
prefills the "Create redirect" modal |
statusUsing(fn (string $path, ?string $language): ?array) |
a badge column in the 404 log, e.g. ['label' => 'Draft', 'color' => 'warning']; hidden when unset |
ignoreUsing(fn (Request $request, string $language, string $path): bool) |
one more reason not to log a 404 |
resolvesUsing must answer for an anonymous visitor: if it shows drafts to a logged-in admin, a draft counts as
found and its 404 row disappears while visitors still get a 404 (the recheck also runs from the panel).
Authorization
Without authorize() the redirects.manage gate decides when it is defined; otherwise everyone who can enter the
panel can use the resources - including pointing any 404 path at an external URL. Set one of the two unless every
panel user is trusted with that. The 404 log item follows the redirects item in the navigation.
Commands and schedule
| Command | |
|---|---|
redirects:flush |
forget the cached map |
redirects:prune {--days=} |
delete 404 rows not seen for retention_days (90) |
redirects:recheck {--dry-run} |
close the rows that now have a redirect or a page |
filament-redirects.schedule.enabled (off by default, needs the Laravel scheduler) runs prune at 03:10 and
recheck at 03:20; both times are configurable.
Configuration
| Key | Default | |
|---|---|---|
tables.redirects / tables.not_found |
redirects / not_found_log |
rename before running the published migrations |
models.redirect / models.not_found |
the shipped models | point at your own subclass to add traits (an audit log, say) |
redirects.cache_store / cache_key |
null / filament-redirects.map |
the map is cached forever and flushed on every save and delete |
redirects.cache_ttl |
3600 |
seconds in Cache-Control: max-age of a 301/308 (see Gotchas) |
redirects.gone_status |
410 |
status served for a Gone redirect |
redirects.preserve_query |
false |
append the request's query string to an internal target |
trailing_slash.canonical |
false |
also 301 every other slashed address to its slashless form |
not_found.enabled |
true |
the 404 log |
not_found.retention_days |
90 |
horizon of redirects:prune |
not_found.max_path_length |
191 |
longer paths are not logged (the column length) |
not_found.ignore / ignore_user_agents / ignore_bots |
see the file | what is never logged |
schedule.enabled / schedule.times |
false |
housekeeping |
Gotchas
- The middleware must be route middleware or appended in
bootstrap/app.php- see above. Nothing will tell you when it is not running;hitsstaying at 0 will. - One hop only. Chains are collapsed when a redirect is saved (saving
B -> Cre-points the existingA -> BtoA -> C). Do not add hop-following to the middleware: it would cost a query per hop on every 404, scanners included. Only permanent rows (301/308) take part in that: a temporaryB -> C(302/307) never re-points other rows, and a new redirect is not flattened through it, so removing a campaign 302 leaves the permanent rows as they were. A redirect into a "Gone" source keeps its target (the Gone row answers). Loops are refused through any active redirect. - A redirect never wins over a real page. It is a fallback for a 404: a row whose source is a live URL is inert.
/old/follows the same rule whenresolvesUsing()is set. - Browsers cache a 301 (and 308) forever, so a later edit (a new target, "Gone") never reaches a visitor who
already followed it. The response carries
Cache-Control: max-age=<cache_ttl>, must-revalidateso changes arrive within an hour. A 301 that is already cached in a browser cannot be recalled; clear that cache once. - Livewire and
Cache-Control. Livewire pushesDisableBackButtonCacheMiddlewareonto the kernel's global stack, and once any Livewire component rendered on a 404 page it overwrites the response'sCache-Controlwithno-store. The redirect response resets that flag, so the boundedmax-agesurvives. - Gone. Stored with code
404, served withredirects.gone_status(default410; set404for plain not-found) on the site's own error page. Unlike an unmatched path it is not logged - it is a decision. - A scanned URL arrives collapsed. A scanner that requests
https://site.com/xas a path leaveshttps:/site.com/xin your log after nginx merges the slashes; the path field recognises that form too and strips your own domain. - Bots in the log.
ignore_botsis off on purpose. Turn it on only if you want humans' broken links alone. - Raw SQL bypasses the cache invalidation. Call
Redirects::flushCache()after it.
Translations
English, Ukrainian, German, Spanish, French, Italian, Dutch, Polish, Brazilian Portuguese and Turkish. Publish to override:
AI agents
The package ships Laravel Boost guidelines (resources/boost/guidelines/core.blade.php)
covering the registry, the middleware trap, the path canon and the write path.
Testing
The tests run on Orchestra Testbench with an in-memory SQLite database and a workbench panel (admin).
Changelog
See the CHANGELOG.
License
MIT - see the LICENSE.
All versions of filament-redirects with dependencies
filament/filament Version ^5.0
illuminate/contracts Version ^12.0|^13.0
spatie/laravel-package-tools Version ^1.16