Materialised Paths
A materialised path is a denormalised string column on a nested-set row that carries the row's ancestry as a separator-joined sequence — /electronics/laptops/ultrabooks/ for URLs, Electronics > Laptops > Ultrabooks for breadcrumbs. The package keeps the column coherent with the tree on every mutation: insert, update, move, rename, bulk insert, subtree clone, tree-diff apply, soft-delete restore, and fixTree. Reorder leaves paths untouched by construction (siblings keep the same parent + segment). Querying remains pure Eloquent — the path is just a column.
1. Quick start
Declare a column on the model with #[NestedSetMaterialisedPath] and add the column to your migration:
use Vusys\NestedSet\Attributes\NestedSetMaterialisedPath;
#[NestedSetMaterialisedPath(column: 'url_path', slug: 'name')]
final class Category extends Model implements HasNestedSet
{
use NodeTrait;
}
Schema::create('categories', function (Blueprint $t) {
$t->id();
$t->string('name');
$t->nestedSet();
$t->string('url_path', 1024)->nullable()->index();
});
That's the whole setup. After $category->save() the column reflects the row's ancestry:
$node->url_path; // '/electronics/laptops/ultrabooks/'
explode('/', trim($node->url_path, '/')); // ['electronics', 'laptops', 'ultrabooks']
2. Sources
Each declaration names exactly one source for the per-row segment. Pick the one that matches what you're storing.
2.1 slug
slug: 'name' runs Str::slug($node->name) per row. Use for URL paths derived from a human-facing display attribute.
#[NestedSetMaterialisedPath(column: 'url_path', slug: 'name')]
2.2 attribute
attribute: 'display_name' reads the column raw, no slugification. Use for breadcrumbs where the rendered text matters ('Electronics > Laptops & Notebooks').
#[NestedSetMaterialisedPath(column: 'crumb_path', attribute: 'display_name', separator: ' > ', wrap: false)]
2.3 key
key: true uses the row's primary key as the segment ('.1.2.3.' for an ancestor chain of ids 1→2→3). Stable, never renamed, but unreadable.
#[NestedSetMaterialisedPath(column: 'id_path', key: true, separator: '.')]
Autoincrement keys aren't known until the INSERT lands, so key-dependent paths are written by a second targeted UPDATE in the saved listener. UUID keys are generated client-side and write inline.
2.4 Closure (method form)
Attributes can't carry closures. For runtime-composed segments use the method form:
use Vusys\NestedSet\MaterialisedPath\MaterialisedPath;
class Article extends Model implements HasNestedSet
{
use NodeTrait;
protected static function materialisedPaths(): array
{
return [
'breadcrumb_path' => MaterialisedPath::from(
static fn (self $node): string => Str::slug($node->title, '-'),
)->separator('/')->maxLength(2048),
];
}
}
The method merges on top of the attribute list; a column appearing in both → method wins. The default implementation in the trait returns [] so attribute-only models pay nothing.
3. Multiple paths
A single model may carry several path columns at once, each derived from a different source attribute or formatted differently. Each column is maintained independently — a rename touching one source attribute writes only the columns whose value actually changes.
#[NestedSetMaterialisedPath(column: 'url_path', slug: 'name')]
#[NestedSetMaterialisedPath(column: 'crumb_path', attribute: 'display_name', separator: ' > ', wrap: false)]
final class Category extends Model implements HasNestedSet { use NodeTrait; }
A change to display_name rewrites crumb_path for the row + every descendant; url_path stays put.
4. Customising defaults
The package resolves a column's effective options through five layers, most specific wins:
- Per-path explicit value — attribute arg or fluent setter on the value object.
#[NestedSetMaterialisedPathDefaults]on the model class (walked through parent classes).config('nestedset.materialised_path.class_defaults.'.$class)— exact FQCN match, nois_awalk.config('nestedset.materialised_path.defaults')— global fallback.- Package hard-coded fallback:
separator: '/',wrap: true,maxLength: 1024,rejectSeparatorInSegment: true,uniquePerParent: true.
Per-path on the attribute:
#[NestedSetMaterialisedPath(column: 'url_path', slug: 'name', separator: '.', wrap: false)]
Class-level:
#[NestedSetMaterialisedPathDefaults(separator: '.', wrap: false, maxLength: 2048)]
#[NestedSetMaterialisedPath(column: 'numeric_path', key: true)]
#[NestedSetMaterialisedPath(column: 'doc_path', slug: 'reference')]
class DocumentNode extends Model implements HasNestedSet { use NodeTrait; }
Per-class config:
// config/nestedset.php
'materialised_path' => [
'defaults' => ['separator' => '/', 'wrap' => true, 'maxLength' => 1024],
'class_defaults' => [
\App\Models\Category::class => ['separator' => '.', 'wrap' => false],
],
],
class_defaults keys are exact FQCN with no inheritance walk — if you want different defaults for two subclasses, list both. Strong use case: overriding a vendor model whose class you can't decorate.
5. Reading paths
The path is a column. Read it like any other column. The only read-side affordance the package provides is materialisedPathFor() which returns the resolved MaterialisedPath value object — useful when a caller needs the separator without parsing it back out of the string.
$node->url_path; // '/electronics/laptops/'
$node->materialisedPathFor('url_path')->getSeparator(); // '/'
No formatPath(), no Stringable wrapper, no breadcrumb helper — explode handles it.
6. Validation and exceptions
| Condition | Exception | Default |
|---|---|---|
| Segment is the empty string | EmptyPathSegment |
Throw |
| Segment contains the separator | InvalidPathSegment |
Throw, unless rejectSeparatorInSegment(false) → silently strip |
| Two siblings produce the same segment under one parent | DuplicatePathSegment |
Throw, unless uniquePerParent(false) |
Computed path exceeds maxLength |
PathTooLong |
Throw |
| Builder is non-deterministic (dev only) | NonDeterministicPathSegment |
Throw when APP_DEBUG=true |
| Attribute declares zero or multiple sources | MaterialisedPathConfigurationException |
Throw at boot |
Per-parent uniqueness comparison is byte-exact (strcmp). For case-insensitive collision detection, lowercase inside the segment builder itself (MaterialisedPath::from(fn ($n) => Str::lower(Str::slug($n->name)))). No comparator config knob — collation semantics are too varied to wrap.
7. Repair
fixMaterialisedPaths() walks the tree by parent_id and rebuilds every declared column for every row. Useful when the structural tree is consistent but a path column has drifted — manual SQL edits, pre-feature backfill rows, or a bulk job run inside withoutMaterialisedPathMaintenance().
Category::fixMaterialisedPaths(); // every declared column
Category::fixMaterialisedPaths('url_path'); // just one column
Category::fixMaterialisedPaths(anchor: $rootCategory); // limit to a subtree
fixTree() calls this as its final step, so structural repair and path repair always run together. The result's materialisedPathsRepaired field is a column => row-count map; cross-link the corruption reference for the full taxonomy.
8. Bypassing maintenance
For bulk renames where running the listener N times is wasteful:
Category::withoutMaterialisedPathMaintenance(function (): void {
foreach (Category::cursor() as $node) {
$node->name = strtolower((string) $node->name);
$node->save();
}
});
Category::fixMaterialisedPaths();
The bypass counter is reentrant. Wrapping wrappers compose. No async-by-default job ships — the supported pattern for very large bulk renames is the bypass + a follow-up fixMaterialisedPaths(), which the user can dispatch to a queue themselves.
9. Performance
| Operation | With N declared paths |
|---|---|
| Insert leaf, no key-dependent paths | unchanged — paths set inline at INSERT |
| Insert leaf, K key-dependent paths | + K UPDATEs (one per key-dep column) |
| Move leaf | + up to N UPDATEs, leaf subtree is one row → cheap; unchanged paths skipped |
| Move subtree | + up to N UPDATEs, each bounded by subtree size; unchanged paths skipped |
| Rename source attribute touching M paths | + M batched UPDATEs (one per column, subtree-bounded) |
| Sibling reorder | unchanged — no path columns touched |
| Subtree clone | + path generation inside the existing clone transaction |
fixTree() |
+ (declared paths × N) recomputes, batched per column |
| Breadcrumb fetch | 0 DB hits — explode the column |
One extra UPDATE per changed path per mutation, all bounded by subtree size, all single-statement.
10. Limitations
The following are out-of-scope by design — they're not "coming soon":
- Query helpers: no
whereDescendantOfPath,wherePathEquals. Writewhere('url_path', 'like', $prefix.'%')in your own scopes; Eloquent handles it. formatPath()/ Stringable wrapper / breadcrumb helper: the column is a string;explodeworks.- Blueprint macro extension: column names, lengths, and indexability are migration decisions.
- Automatic indexing: index strategy and prefix length depend on backend / query shape.
- Async-by-default maintenance: use
withoutMaterialisedPathMaintenance()+fixMaterialisedPaths(). - Events on path changes: existing
saving/savedevents fire naturally; subscribers comparegetOriginal($col)to$colif they care.