asconsulting / contao-zyppy-redirect
Back end crafted URL redirects for Contao, with first-order and passive execution tiers.
Package info
github.com/asconsulting/contao-zyppy-redirect
Type:contao-bundle
pkg:composer/asconsulting/contao-zyppy-redirect
Requires
- php: ^8.1
- contao/core-bundle: >=5.3
- doctrine/dbal: >=3.6
- psr/cache: >=2.0
- symfony/dependency-injection: >=6.4
- symfony/event-dispatcher: >=6.4
- symfony/http-foundation: >=6.4
- symfony/http-kernel: >=6.4
- symfony/routing: >=6.4
- symfony/security-core: >=6.4
Requires (Dev)
- contao/manager-plugin: ^2.0
- contao/test-case: >=5.3
- phpunit/phpunit: ^9.6 || ^10.5 || ^11.0 || ^12.0
- symfony/cache: >=6.4
Suggests
None
Provides
None
Conflicts
- contao/manager-plugin: <2.0 || >=3.0
Replaces
None
README
Back end crafted URL redirects for Contao 5.3+, with per-rule control over when in the request lifecycle a rule is allowed to fire.
Replaces asconsulting/redirect_manager, whose rules could only ever run on
the 404 page. Existing rules import automatically — see Migration from legacy
version below.
Requirements
- PHP 8.1 or newer
- Contao 5.3 or newer, with no upper bound. The Zyppy Suite supports the LTS releases and is tested against the current non-LTS release as well: the test suite passes on core-bundle 5.3.0, 5.7.13 and 6.0.2 (last run 2026-10-07).
Installation
composer require asconsulting/contao-zyppy-redirect:^5.0
Then run the database migration. Contao Manager does this on install and on
update; on the command line it is vendor/bin/contao-console contao:migrate.
It creates the tl_zyppy_redirect table and, if the old redirect_manager
table is present, imports its rules.
Usage
Creating a rule
Open Design > Redirects and add a rule. Only back end administrators see the module — see Permissions below.
| Field | Meaning |
|---|---|
| Category | An arbitrary label for grouping. Searchable and filterable. |
| Precedence | Lower numbers are checked first when more than one rule matches the same URL. |
| Execution tier | Passive or First order — see The two execution tiers. |
| Match mode | How the incoming URL is compared with the source — see Match modes. |
| Source | The path to match, e.g. /old-page. In domain mode, the hostname to match. Include a query string only if the rule should match that query string alone. |
| Restrict to host | Apply the rule on this domain only. Empty applies it on every domain. |
| Target type | URL, Contao page, File, or Another host, same path. |
| Target URL / page / file / host | The destination, according to the target type. In regex mode, $1 … $n in a target URL insert the captured groups. |
| HTTP status code | 301, 302, 303, 307 or 308. |
| Publish rule, Show from, Show until | Enable the rule, optionally for a date range. |
The two execution tiers
Every rule carries a tier, and this is the feature the old module did not have.
| Tier | Fires | Use it when |
|---|---|---|
| Passive (default) | Only after every Contao page, route and file has failed to resolve — i.e. at the 404 | The URL is genuinely gone. This is the safe default and what you want almost every time |
| First order | Before Contao routes the request | You need to override a page that still exists — retiring a live section, forcing a canonical domain, moving a URL whose old page has not been deleted yet |
A first-order rule wins against a real Contao page. That is the point of it, and also the reason the default is passive: a rule created without thinking cannot take a live page offline.
Out of scope: anything above Contao at the host level. A physical
index.html in the document root is served by the web server before PHP runs,
and nothing in this bundle can override it.
Paths no rule can redirect
Both tiers refuse to act on a short list of prefixes, matched on a path
segment boundary — so /contaoshop is redirectable while /contao/… is not.
A rule pointed at one of these is stored and shown in the back end but will
never fire, which is the one case where a rule can look correct and do
nothing.
| Prefix | Why |
|---|---|
/contao, /_contao |
The back end, and Contao's front end controller routes. A first-order rule here would lock you out of the site that defines the rule |
/_wdt, /_profiler, /_fragment |
Symfony's profiler, toolbar and fragment handler |
/assets |
contao_images generates images on the fly at /assets/images/{path} |
/bundles, /files, /system |
Asset namespaces. A redirect aimed into one is far more likely to be a mistake than an intent, so they are excluded deliberately |
/share is not on this list, despite Contao's installer creating a
public/share directory. The real share route is /_contao/share, already
covered above, so a page at /share is ordinary site space and stays
redirectable.
Match modes
| Mode | Matches | Notes |
|---|---|---|
| Exact path | /old-page |
One indexed lookup. Stays fast on a table with hundreds of thousands of rows |
| Path prefix | /old and everything under /old/ |
Does not match /older. The unmatched tail is appended to a URL or host target |
| Regular expression | Whatever the pattern matches | Include delimiters: #^/news/(\d+)$#. $1 … $n insert captured groups into the target URL |
| Domain | The whole hostname | source holds the host to match. The incoming path is carried across unchanged |
Query strings
A rule stored as /old-page matches whatever the query string is, and the
incoming query is forwarded onto the target.
Note that Contao itself strips tracking parameters — utm_*, gclid,
fbclid, msclkid and similar — at its HTTP cache layer, before any
application code runs. Those never reach a redirect rule and so are not
forwarded. That is core behaviour, not something this bundle does. A rule stored as
/old-page?id=7 matches only that exact query, and does not forward it
again. If the target already carries its own query string, the target's wins.
Performance
The bundle is built for installations with very large redirect tables.
- Exact rules are never loaded into memory. They resolve through one bound,
indexed
SELECT … LIMIT 1. - A tier holding no rules costs no database access per request — one cache
read and the listener returns. Measured on a live site with an empty table:
zero queries against
tl_zyppy_redirectacross 152 application queries covering both a 404 and a rendered page. Rebuilding a tier's cache entry costs two queries and happens at most once per 60 seconds, not once per request. On a site with no first-order rules, which is most sites, thekernel.requestlistener is effectively free. - Pattern rules are compiled into the application cache, one entry per tier, rebuilt when you save, delete, copy or restore a rule. Only patterns live in memory, and a site typically has very few.
- Publish state is computed, never written. The old module ran two
UPDATEstatements on every 404 and on every back end list load; this one evaluatesstart/stopin the query. - Cost on a non-404 response for the passive tier is a single integer comparison.
The HTTP cache caveat — read this one
Contao's HTTP cache serves a page without running PHP. Neither listener fires for a URL that is already in the cache, so a first order rule added for an existing page does not take effect until that cache entry is invalidated.
This looks fine on a development site, where the cache is usually cold, and does not work in production. After adding a first-order rule for a URL that already resolves, invalidate the cache.
Redirect responses themselves are sent with no-store, so the redirect never
becomes a cached page in its own right.
Permissions
tl_zyppy_redirect is restricted to back end administrators by a data
container voter.
This is deliberate rather than conservative: a user who can create a redirect
can point any URL on the site — its home page included, at the first-order
tier — at an arbitrary external target. That is a site-defacement capability,
so it is not grantable through tl_user_group.
Migration from legacy version
This bundle replaces asconsulting/redirect_manager. Install this bundle and
run contao:migrate. Rules in tl_asc_redirect are copied into
tl_zyppy_redirect automatically.
- The old table is not touched. Both bundles can sit installed side by side while you check the result; the old module keeps working until you uninstall it.
- The import is idempotent. It is keyed on the legacy row id, so rules
added to the old table later are picked up on the next
contao:migrate. - Every imported rule lands as passive. That is exactly what the old module did — it was a front end module on the 404 page, so no rule could fire before routing. Promoting one to first order is a deliberate, per-rule decision you make afterwards.
- The old module's implicit
target URL > target page > target fileprecedence becomes an explicit target type you can see in the palette. - Empty
start/stopdates becomeNULL, not0. A stored0renders as 1969-12-31 and breaks saving on servers west of UTC.
After importing, the redirect_404 front end module can be removed from your
404 page and the old bundle uninstalled.
Development
composer install
vendor/bin/phpunit --no-coverage
TODO.md records the test matrix run and what remains open.
Licence
Licensed under the GNU Affero General Public License v3.0 only (AGPL-3.0-only). See LICENSE. Copyright Andrew Stevens.