huluti/breadcrumbs-bundle
Huluti Breadcrumbs Bundle provides configurable breadcrumb navigation for Symfony applications. Define trails in PHP or YAML, connect them to routes, and render accessible breadcrumbs in Twig templates with minimal setup and flexible customization.
First, a reality check: huluti/breadcrumbs-bundle is a Symfony bundle, not a Laravel package. There's no service provider, no Blade directive, no config/breadcrumbs.php auto-merge. If you're evaluating this from a Laravel codebase, treat it as reference architecture rather than a dependency you can composer require and wire into config/app.php.
For a Symfony app, the minimal path is:
composer require huluti/breadcrumbs-bundle:^1.7
The ^1.7 constraint matters: 1.7.0 is the release that added attribute support, which is now the fastest way to get a working trail.
Register it in config/bundles.php:
return [
// ...
Huluti\BreadcrumbsBundle\HulutiBreadcrumbsBundle::class => ['all' => true],
];
Then look at three places first:
vendor/huluti/breadcrumbs-bundle/Resources/views/ (or templates/) for the default Twig template you'll almost certainly override.The first real use case is almost always a controller action. As of 1.7.0 the attribute path is supported natively:
use Huluti\BreadcrumbsBundle\Attribute\Breadcrumb; // confirm exact namespace in vendor/
class ProductController extends AbstractController
{
#[Breadcrumb('Home', route: 'app_home')]
#[Breadcrumb('Products', route: 'app_product_index')]
public function show(Product $product): Response
{
// ...
}
}
The service-based API still exists and is unchanged — use it when labels are dynamic:
$breadcrumbs = $this->get('huluti_breadcrumbs'); // check the actual service ID
$breadcrumbs->addItem('Home', $this->generateUrl('app_home'));
$breadcrumbs->addItem($product->getName(), $this->generateUrl('app_product_show', ['id' => $product->getId()]));
Render once in your base template, right above the content block:
{{ huluti_breadcrumbs() }}
If that function name doesn't exist, grep the bundle for TwigFunction — the naming convention varies (render_breadcrumbs, breadcrumbs, etc.).
Attributes on controller methods for static trails — now first-class. 1.7.0 makes this the default recommendation rather than a "maybe your fork has it" hedge. Put the attribute on the method, not the class, so index/show/edit can diverge. This is the pattern that scales best because the trail is versioned with the route and needs no service wiring. Ordering follows declaration order, so read your controller top-to-bottom to read the trail.
Listener-based dynamic trails for entity routes. For anything with a detail page (/orders/42/items/7), build breadcrumbs in a kernel or a controller trait rather than in attributes, because the label depends on the entity:
final class BreadcrumbListener
{
public function __construct(private BreadcrumbsInterface $breadcrumbs) {}
public function onKernelController(ControllerEvent $event): void
{
$order = $event->getRequest()->attributes->get('order');
if ($order instanceof Order) {
$this->breadcrumbs->addItem($order->getReference(), /* url */);
}
}
}
Hybrid: static prefix in attributes, dynamic tail from a listener. Now that attributes exist, the cleanest split is attributes for the fixed hierarchy (Home > Orders) and one listener (or a BreadcrumbBuilder call from the action) for the entity crumb. Be deliberate about this, because the bundle does not deduplicate — see Gotchas.
Centralized trail builder for multi-tenant / localized apps. Don't sprinkle addItem calls across controllers. Create an application-level BreadcrumbBuilder that wraps the bundle service and takes a domain object, then returns the trail. This gives you one place to swap labels for translations and one place to change behavior when you migrate off the bundle.
Translation of labels. Pass label keys, not rendered strings, if the bundle accepts them — then let the template or a Twig filter resolve them:
{{ huluti_breadcrumbs() }} {# default template usually applies |trans if configured #}
If it doesn't translate automatically, override the template (see Gotchas) and add |trans({}, 'breadcrumbs').
Template override as the extension point. The typical pattern for Symfony bundles applies: copy the bundle's template into templates/bundles/HulutiBreadcrumbsBundle/... and restyle. This is where you add schema.org BreadcrumbList JSON-LD, Bootstrap classes, or Tailwind markup without touching vendor code.
If you're porting the idea to Laravel, the translation is straightforward: the "attribute on controller" pattern maps to a route middleware or a Route::breadcrumb() macro; the "addItem" pattern maps to diglactic/laravel-breadcrumbs. The conceptual surface is small — this bundle is roughly 200–400 lines of glue. Don't take a dependency for it in Laravel; reimplement.
The 1.7.0 upgrade is additive but not visible until cache clear. Adding attribute support means a new compiler pass and a new attribute class. On an existing install, composer update alone can leave you with a stale compiled container where the attribute reader isn't registered — attributes then silently do nothing. Run bin/console cache:clear (and cache:clear --env=prod if you deploy warm) immediately after upgrading.
Attribute class name and namespace need confirming, not guessing. Breadcrumb, BreadcrumbItem, and Huluti\BreadcrumbsBundle\Attribute\* are all plausible. Grep vendor/huluti/breadcrumbs-bundle/src for #[\Attribute before writing ten controllers against a guess. A wrong attribute class is a fatal error; a right class with wrong named arguments is also a fatal error, so prototype one action first.
Symfony version pinning is the whole risk. With 9 stars, 0 known dependents, and a release dated 2026, this is a low-traffic bundle. Pin the exact version in composer.json and run composer why-not symfony/framework-bundle before upgrading Symfony. Expect to fork if a future Symfony deprecates ControllerEvent or changes Twig extension registration — and note that attribute loading depends on container-compile internals, which is a more volatile surface than runtime service calls.
Service ID and Twig function names are not stable across forks. This repo has lineage from whiteoctober/BreadcrumbsBundle; if you've seen wo_render_breadcrumbs() in an older tutorial, don't assume it applies here. Confirm by grepping vendor/huluti/breadcrumbs-bundle/src for TwigFunction.
Empty trails render empty markup. Guard in your template:
{% set crumbs = huluti_breadcrumbs() %}
{% if crumbs is not empty %}<nav aria-label="breadcrumb">{{ crumbs }}</nav>{% endif %}
Otherwise every 404 and error page gets an empty <nav> that fails accessibility linting.
Cache invalidation after template override. Symfony caches compiled Twig templates. After copying a bundle template into your app, bin/console cache:clear — otherwise you'll swear the override isn't taking effect.
Order matters and is not enforced. Attribute-order and addItem-call-order are the only ordering mechanisms. There's no priority parameter. If a listener and an attribute both add items, you'll get a duplicated "Home" crumb. Pick one pattern per app — or, if you must mix, pick one level (attributes for ancestors, listener for the current entity) and never let both emit the same crumb.
Route names vs. URLs. Prefer passing route names where the API allows it; hardcoded paths break the moment you add a locale prefix (/en/...) or move a route. The 1.7.0 attribute takes a route: argument in the example above, but verify whether it also accepts route parameters — entity routes usually need route: 'app_product_show', params: {...}, and if that's unsupported you're back to the service API for detail pages.
Translation domain is a common miss. Bundles default to the messages domain. If you ship breadcrumbs.en.yaml, you'll need to either configure the domain or override the template — otherwise you'll see raw keys in production and think the bundle is broken.
Don't rely on this as your only trail. If the bundle is abandoned, you want a one-file escape hatch: make all your controllers/listeners depend on your BreadcrumbBuilder interface, and let the bundle be the only implementation class that references it. Migration then becomes a one-class swap rather than a grep-and-replace across the codebase — and when 1.7.0's attribute API changes in 2.x, you'll be editing one class instead of every controller.
How can I help you explore Laravel packages today?