saucebase/breadcrumbs
Saucebase Breadcrumbs is a Laravel package for building dynamic breadcrumb trails with a simple, fluent API. Define crumbs in routes or Blade views, render accessible markup, and customize separators, labels, and styling for clean navigation in any Laravel app.
The package is a fork of diglactic/laravel-breadcrumbs with the Blade rendering layer removed. Everything about defining breadcrumbs is identical to upstream; everything about rendering is now your job. Assume the Diglactic\Breadcrumbs namespace is preserved (check the readme — some forks rename to Saucebase\Breadcrumbs); adjust imports accordingly.
1. Install
composer require saucebase/breadcrumbs
The service provider is auto-discovered. You only need to publish config if you want to change where definition files are loaded from:
php artisan vendor:publish --tag=breadcrumbs-config
# -> config/breadcrumbs.php with a 'files' key pointing at routes/breadcrumbs.php
Pin at ^1.1 — v1.1.0 is the first release that doesn't double-register the definition files from the service provider.
2. Define your first trails in routes/breadcrumbs.php:
use Diglactic\Breadcrumbs\Breadcrumbs;
use Diglactic\Breadcrumbs\Generator as Trail;
Breadcrumbs::for('home', fn (Trail $trail) => $trail->push('Home', route('home')));
Breadcrumbs::for('posts.show', function (Trail $trail, $post) {
$trail->parent('home');
$trail->push($post->title, route('posts.show', $post));
});
3. Use it. There is no render(). Breadcrumbs::generate('posts.show', $post) returns a Breadcrumbs collection (an Arrayable of ['title' => ..., 'url' => ..., 'data' => [...]]). That collection is the entire integration surface:
// app/Http/Controllers/PostController.php
public function show(Post $post)
{
return view('posts.show', [
'post' => $post,
'breadcrumbs' => Breadcrumbs::generate('posts.show', $post)->toArray(),
]);
}
Where to look first: routes/breadcrumbs.php (or wherever config('breadcrumbs.files') points), then the Generator class for push()/parent() semantics. Skip the config file entirely unless you're relocating definitions.
Base controller helper. Since you're rendering yourself, wrap generation once so every controller stays terse:
abstract class Controller
{
protected function crumbs(string $name, mixed ...$params): array
{
return Breadcrumbs::generate($name, ...$params)->toArray();
}
}
Inertia. Pass the array straight through as a shared prop from a middleware or a base HandleInertiaRequests:
public function share(Request $request): array
{
return [
...parent::share($request),
'breadcrumbs' => fn () => $request->route()
? Breadcrumbs::generate(...$this->resolveTrail($request))->toArray()
: [],
];
}
Guarding with Breadcrumbs::exists($routeName) avoids exceptions on routes you haven't defined a trail for.
Livewire. Generate inside a computed property so it recalculates on mount, not on every render:
#[Computed]
public function breadcrumbs(): array
{
return Breadcrumbs::generate('posts.show', $this->post)->toArray();
}
Blade partial. One tiny resources/views/partials/breadcrumbs.blade.php looping $items and using $item['url'] === null to render the current page as plain text:
<nav aria-label="Breadcrumb">
<ol>
@foreach ($items as $item)
<li>
@if ($item['url'])
<a href="{{ $item['url'] }}">{{ $item['title'] }}</a>
@else
<span aria-current="page">{{ $item['title'] }}</span>
@endif
</li>
@endforeach
</ol>
</nav>
Structured data / API. The array shape is stable, so a JSON-LD macro is a natural extension point:
Breadcrumbs::macro('jsonLd', function (string $name, mixed ...$params) {
return [
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => Breadcrumbs::generate($name, ...$params)
->values()
->map(fn ($c, $i) => [
'@type' => 'ListItem',
'position' => $i + 1,
'name' => $c->title,
'item' => $c->url,
])->all(),
];
});
Parent chaining is the main DRY lever. Define home once, then have every section define a single crumb and call $trail->parent(...). For nested resources, chain two levels:
Breadcrumbs::for('comments.show', function (Trail $trail, Post $post, Comment $comment) {
$trail->parent('posts.show', $post);
$trail->push('Comment #'.$comment->id, route('comments.show', [$post, $comment]));
});
Trailing crumb convention. Always push() the current page with a null URL (or omit the second argument). Consumers then treat url === null as the active page without any extra flags.
Use the $data array for anything presentational — icons, aria attributes, a current boolean — rather than baking HTML into titles. Nothing in the package renders it, so it's a free-form bag your component reads.
render() and view() do not exist. This is the whole point of the fork, and it's the number-one failure when migrating an existing diglactic install: Call to undefined method Breadcrumbs::render(). Search-and-replace Breadcrumbs::render( → Breadcrumbs::generate( and move the loop into your own view/component. The published config/breadcrumbs.php may still carry a vestigial view key that is silently ignored — don't waste time tuning it.
Closure signatures are positional and unforgiving. generate('posts.show', $post) splats params into your callback as ($trail, $post). Pass one too few and you get ArgumentCountError; pass an extra and it's ignored, which hides bugs. Keep the generate call and the definition signature in lockstep.
push() with no URL is not the same as push($title, '#'). Use null/omitted so your renderer can distinguish the current page. Passing route('posts.show', $post) on the final crumb produces a self-link that trips up aria-current and screen readers.
Duplicate names overwrite silently. Breadcrumbs::for('home', ...) called twice keeps the last definition — a real hazard when definitions are split across module files. Grep for the name before adding a new registration. Note this is your duplicate, not the provider's: v1.1.0 fixed the service provider registering the configured files twice, so on ≤1.0.0 every definition file was loaded (and evaluated) twice — harmless-looking because the second registration silently clobbered the first, but it doubled any side effects in your definition files. If you're on 1.0.0, bump before debugging "why did my definition run twice."
Registration order is flexible, but resolution order isn't. parent() resolves lazily at generate() time, so you can register a child before its parent. What you can't do is call parent() on a name that never gets registered — that throws at render time, in production, on the one page nobody tested.
Missing routes throw. If a named route is renamed or removed, route() inside a definition throws RouteNotFoundException mid-request. For crumbs pointing at optional features (e.g. a disabled module), guard:
$trail->push('Reports', Route::has('reports.index') ? route('reports.index') : null);
Absolute vs. relative URLs. route() defaults to absolute. For Inertia/SPA navigation you often want relative — pass false as the third argument: route('posts.show', $post, false).
The collection is regenerated on every call. It's not cached, and mutating the returned collection doesn't persist. If a definition does a DB query (resolving an author's name, say), you're paying for it on every generation — cache inside the closure or denormalize the title onto the model.
Definitions load per request via the files config. If you move breadcrumbs into app/Modules/*/routes/breadcrumbs.php, add those paths to config/breadcrumbs.files or they'll never register. Conversely, a stale published config from an older install may point at a file you deleted — check it first when all breadcrumbs come back empty.
Testing is straightforward because there's no HTML to assert on:
$crumbs = Breadcrumbs::generate('posts.show', $post)->toArray();
$this->assertSame('Home', $crumbs[0]['title']);
$this->assertNull($crumbs[1]['url']); // active page
Assert on exists() for the negative case rather than catching exceptions.
Pin the version. Zero stars, a fork, and a 2026 release date means the API can drift from upstream diglactic without a major-version signal. v1.1.0 is a single-line patch (the double-registration fix) with no API change, so ^1.1 is the safe constraint — but re-read the changelog before bumping, because the divergence will be in the rendering-adjacent APIs, which is exactly what you're building on.
How can I help you explore Laravel packages today?