kunstmaan/redirect-bundle
KunstmaanRedirectBundle is a Symfony bundle for managing URL redirects from an admin interface. Create and maintain redirects to handle moved content, avoid broken links, and preserve SEO when URLs change.
This is a Symfony 2 bundle, not a Laravel package. If you're coming from Laravel, read it as the equivalent of spatie/laravel-redirects — a Doctrine-backed table of 301/302 rules with an admin CRUD screen bolted onto Kunstmaan's CMS admin. You can't composer require it into a Laravel app (and probably shouldn't; last release was April 2014, 1 star, zero dependents). Use it as a reference implementation or install it inside a legacy Symfony 2.3–2.8 app that already runs Kunstmaan Bundles.
Prerequisites
symfony/framework-bundle, doctrine/ormkunstmaan/admin-bundle + kunstmaan/node-bundle (the admin UI, layout Twig templates, and AbstractEntity base class live there)Install and wire up
composer require kunstmaan/redirect-bundle:dev-master
// app/AppKernel.php
$bundles = [
// ...
new Kunstmaan\AdminBundle\KunstmaanAdminBundle(),
new Kunstmaan\NodeBundle\KunstmaanNodeBundle(),
new Kunstmaan\RedirectBundle\KunstmaanRedirectBundle(),
];
# app/config/routing.yml
kunstmaan_redirect:
resource: "@KunstmaanRedirectBundle/Resources/config/routing.yml"
prefix: /{_locale}/admin
php app/console doctrine:schema:update --force # creates the kuma_redirects table
Where to look first, in this order
EventListener/RedirectListener.php — the actual runtime behaviour (a kernel.request listener that short-circuits with a RedirectResponse). Everything else is CRUD around it.Entity/RedirectRepository.php — the lookup semantics (getRedirectFor($domain, $origin)), including the null-domain fallback.AdminList/RedirectAdminListConfigurator.php — which columns/actions the editor sees, and where to add filters or an export.Resources/config/services.yml — listener priority; you will likely touch this.README.md — short; the code is the documentation.First use case
Log into the admin, create a redirect with origin = /old-page, target = /new-page, permanent = true, then verify from the outside:
curl -I http://localhost/old-page
# HTTP/1.1 301 Moved Permanently
# Location: http://localhost/new-page
Editorial workflow vs. developer workflow. Editors create rules one at a time in the admin UI ("we killed this landing page"). Developers should seed and bulk-manage rules via fixtures or a console command, because typing 400 legacy URLs into a CMS list widget is not a plan.
Bulk import of a legacy URL map — the single highest-value use of this bundle:
// src/Acme/SiteBundle/Command/ImportRedirectsCommand.php
$em = $this->getContainer()->get('doctrine.orm.entity_manager');
$repo = $em->getRepository('KunstmaanRedirectBundle:Redirect');
foreach (array_map('str_getcsv', file($csvPath)) as list($origin, $target)) {
$origin = '/'.ltrim(strtolower(trim($origin)), '/');
if (!$redirect = $repo->findOneBy(['origin' => $origin, 'domain' => null])) {
$redirect = new Redirect();
$redirect->setOrigin($origin);
$redirect->setDomain(null);
}
$redirect->setTarget($target);
$redirect->setPermanent(true);
$em->persist($redirect);
}
$em->flush();
Upsert by (domain, origin) rather than blind-insert; re-running the import is then safe.
Domain scoping for multi-site. The domain column holds a host; null acts as a wildcard used by the repository's domain = :domain OR domain IS NULL lookup. Convention that works: store null for rules that apply everywhere, and an explicit host only when the same path must go to different targets per site. Be aware the fallback means a specific-domain rule cannot "block" a wildcard rule — the wildcard still matches on other hosts.
Permanent vs. temporary. permanent = true emits 301, otherwise 302. Default to 301 for anything that represents a permanent IA change (SEO link equity), 302 for campaign/seasonal paths where the old URL will come back. Do not use 301 for anything you might untangle later — browsers cache it forever.
Keeping the map honest when slugs change. The bundle does nothing on its own here. Hook Kunstmaan's node save/update event: capture the previous URL before persisting a NodeTranslation, then create a Redirect from old path → new path. That is the single integration that makes the table self-maintaining instead of decaying.
Programmatic lookups for your own features. The repository is the right entry point rather than raw DQL:
$redirect = $em->getRepository('KunstmaanRedirectBundle:Redirect')
->getRedirectFor($request->getHost(), $request->getPathInfo());
if ($redirect) {
return new RedirectResponse($redirect->getTarget(), $redirect->isPermanent() ? 301 : 302);
}
Testing. Functional test the listener end-to-end rather than mocking Doctrine; the listener is the whole point:
$client->request('GET', '/old-page');
$this->assertEquals(301, $client->getResponse()->getStatusCode());
$this->assertEquals('/new-page', $client->getResponse()->headers->get('Location'));
If the listener makes unrelated functional tests brittle (it fires on every request), disable it in the test environment's services.yml and cover it in a dedicated test case.
In Laravel terms, the same architecture is a redirects table (origin, target, status, domain nullable), a global middleware that runs before the router, and a Nova/Filament resource on top. The Kunstmaan bundle is a direct analogue and the pattern ports cleanly.
symfony/framework-bundle ~2.x and Kunstmaan's AbstractEntity; it will not resolve against Symfony 3+, let alone Laravel. Pin an exact tag/commit and treat the repo as a reference, or budget a rewrite.kernel.request before the router resolves the path (priority: 32 is the usual value). Too low and the router throws NotFoundHttpException first; too high and you intercept the profiler/admin toolbar. Add early returns for _profiler, _wdt, and your admin route prefix.kernel.request priority means the redirect happens before security, so a redirect on /admin/foo fires for anonymous users too. Usually fine, occasionally an information leak — guard the listener against admin paths.ERR_TOO_MANY_REDIRECTS and you will get a bug report. Run an occasional audit script over the table to detect cycles and chains.origin comparison against getPathInfo(). /blog/* will not work. Prefix or pattern rules require a custom listener (or, in Laravel, a rule with a {wildcard} segment).getPathInfo() keeps trailing slashes; /About and /about are different rows and one will silently 404.?utm_source= is lost. If campaign attribution matters, append $request->getQueryString() in the listener.getHost() returns the port-free host; behaviour behind a load balancer depends on trusted_proxies being configured. Mismatched hosts are the classic "redirect works locally, not in prod" cause.kernel.request — including for 404s. On a content-heavy site, cache the origin→target map (Doctrine result cache or a custom compiler pass writing a PHP array) or the listener becomes your slowest component.origin/domain, blind inserts from a CSV will throw; upsert as shown above. Check the generated table definition before relying on the index existing — 2014-era doctrine:schema:update output varies by Doctrine version.kunstmaan/admin-bundle installed and routed you get blank pages or Twig "template not found" errors rather than a helpful message.RedirectAdminListConfigurator — override buildFields() to show note/domain, buildFilters() to filter by domain, and add getExportFields() if editors want CSV of the whole map. Route to your subclass via the kunstmaan_redirect.admin_controller service definition.Resources/translations/messages.*.yml; add a locale file rather than editing the vendor copy.How can I help you explore Laravel packages today?