jobmetric / laravel-url
It is a package for url and slug storage management in each model that you can use in your Laravel projects.
Requires
- php: >=8.0.1
- ext-json: *
- jobmetric/laravel-package-core: ^1.7
- laravel/framework: >=9.19
README
Url And Slug for laravel Model
It is a package for url and slug storage management in each model that you can use in your Laravel projects.
Install via composer
Run the following command to pull in the latest version:
composer require jobmetric/laravel-url
Documentation
This package gives each Eloquent model:
One canonical slug stored in a dedicated slugs
table (polymorphic one-to-one).
A versioned full URL history stored in the urls
table (polymorphic one-to-many with soft deletes).
Automatic syncing of URLs when the slug or any parent path segment changes.
Global uniqueness for active full URLs, enforced at the application layer.
Soft-delete/restore support for slugs and URLs, with conflict checks on restore.
Run migrations once after installing the package:
php artisan migrate
HasUrl Trait
1) Quick Start
1.1 Add the trait and implement UrlContract
HasUrl
relies on your model implementing UrlContract
(i.e. it must expose how to compute its full URL).
use Illuminate\Database\Eloquent\Model; use JobMetric\Url\Contracts\UrlContract; use JobMetric\Url\HasUrl; class Product extends Model implements UrlContract { use HasUrl; // Example: build the current full URL from your own fields and relations public function getFullUrl(): string { // e.g. /shop/{categorySlug}/{productSlug} $categorySlug = optional($this->category)->slug ?? 'uncategorized'; $selfSlug = $this->slug ?? 'product-'.$this->getKey(); return "/shop/{$categorySlug}/{$selfSlug}"; } }
Why
UrlContract?
The package must know the current full path for the model. getFullUrl()
is your canonical builder for that path.
1.2 Create a record and assign a slug
You don’t set columns on the urls
table yourself. You only provide a slug
(optional slug_collection
) and the package will do the rest.
$product = Product::create([ 'name' => 'MacBook Pro 14', ]); // Persist a slug and sync the versioned URL $product->dispatchSlug('macbook-pro-14', 'products'); // collection is optional; see §3.1
- The package slugifies and length-limits your input (100 chars).
- It stores one row in slugs (per model).
- It creates version=1 in urls.
- It guarantees no active conflict with another model’s active full URL.
1.3 Read back the slug and current full URL
$product->slug; // "macbook-pro-14" (accessor) $product->slug_resource; // SlugResource with slug + collection $product->slug_collection; // "products" (or your default) $product->getActiveFullUrl(); // e.g. "/shop/laptops/macbook-pro-14"
2) What the package does for you
-
Keeps one slug per model. (Polymorphic
slugs
table) -
Tracks full URL history. (
urls
rows are versioned; active row = highestversion
withdeleted_at NULL
) -
Auto-version on changes. If the computed full URL changes, the previous URL is soft-deleted and a new version is inserted.
-
Conflict safety.
-
Throws
SlugConflictException
if another record uses the same slug in the same collection. -
Throws
UrlConflictException
if another active record uses the same full URL.
-
-
Cascade refresh. If your model’s URL affects children, you can cascade (see §5).
-
Delete/restore aware. Soft delete removes active slug/URL from public view; restore validates conflicts and re-syncs.
3) Slugs & Collections
3.1 One slug, optional collection
Each model gets exactly one slug row. You may tag it with a collection (e.g. to group slugs by context).
// Set/change slug with an optional collection $product->dispatchSlug('mbp-14', 'products');
If you omit the collection:
-
If your model defines
getSlugCollectionDefault(): ?string
, it will be used. -
Else, if your model has an attribute
type
, that value becomes the collection. -
Else, collection is
NULL
.
3.2 Reading by collection
// Return the SlugResource envelope for the default collection $product->slug(); // Return the SlugResource envelope for a specific collection $product->slugByCollection('products'); // Just the slug string (mode=true) $product->slugByCollection('products', true); // "mbp-14"
3.3 Finding models by slug
// Search across all collections for this model type Product::findBySlug('mbp-14'); // Require a specific collection Product::findBySlugAndCollection('mbp-14', 'products'); // ...or throw SlugNotFoundException on miss Product::findBySlugOrFail('mbp-14'); Product::findBySlugAndCollectionOrFail('mbp-14', 'products');
3.4 Removing the slug
// Remove slug if (optionally) the collection matches $product->forgetSlug('products');
Note: A model without a slug can still compute a full URL if your
getFullUrl()
does not depend on it, but most setups will.
4) Versioned full URLs
4.1 Automatic syncing
You do not call any URL method on normal saves; syncing happens transparently in the trait:
-
On
saving
: caches the pre-save full URL. -
On
saved
:- upserts the slug (if provided this request),
- computes the new full URL via
getFullUrl()
, - if first time → insert version
1
, - if changed → soft-delete previous active row and insert
version+1
, - fires
UrlChanged
event with(model, oldFullUrl|null, newFullUrl, newVersion)
.
4.2 Read the current URL or full history
// Current active full URL (without recomputing) $current = $product->getActiveFullUrl(); // e.g. "/shop/laptops/mbp-14" // Full history (active + trashed by default) $history = $product->urlHistory(); // Collection of Url models ordered by version asc $activeOnly = $product->urlHistory(withTrashed: false);
4.3 Resolving owners and redirects
// Who currently owns a given active full URL? $model = \JobMetric\Url\Models\Url::resolveActiveByFullUrl('/shop/laptops/mbp-14'); // If a URL is old (trashed), where should we redirect? $target = \JobMetric\Url\Models\Url::resolveRedirectTarget('/shop/old-path'); // returns a current active URL string or null
Example redirect middleware (simplified):
use Closure; use JobMetric\Url\Models\Url; class CanonicalRedirectMiddleware { public function handle($request, Closure $next) { $path = '/'.ltrim($request->getPathInfo(), '/'); // If requested path is obsolete, redirect to its canonical target if ($target = Url::resolveRedirectTarget($path)) { return redirect($target, 301); } return $next($request); } }
5) Cascading URL updates to descendants
If a parent’s path segment changes (e.g., a Category slug), you may need to refresh child URLs (e.g., Products). The trait supports this via an optional method on your model:
// On the PARENT model public function getUrlDescendants(): iterable { // Return children whose URLs depend on this model return $this->products; // iterable of Models implementing UrlContract }
When the parent’s slug changes, HasUrl
will:
- Re-compute each child’s
getFullUrl()
, - Version and insert the new active URL if changed,
- Throw
UrlConflictException
if a child’s new full URL conflicts with another model.
Temporarily disable cascade:
$category->withoutUrlCascade(function () use ($category) { // Slug change without touching descendants $category->dispatchSlug('new-category-slug'); });
6) Soft delete, restore, and force delete
-
Soft delete the parent model
-
The single
slugs
row is soft-deleted. -
All active
urls
rows are soft-deleted.(No model will claim the path anymore.)
-
-
Restore the parent model
-
Before restoring, it checks slug conflicts (same type & collection).
-
On restored, it restores the
slugs
row and re-syncs the URL.If the full URL is already taken by another active record, it throws
UrlConflictException
.
-
-
Force delete the parent model
- Permanently removes its slug and all URL history.
Examples
// Soft delete $product->delete(); // Restore (may throw SlugConflictException or UrlConflictException) $product->restore(); // Permanently delete $product->forceDelete();
7) Rebuilding URLs in bulk
Useful after changing your getFullUrl()
logic or migrating data.
use JobMetric\Url\Contracts\UrlContract; Product::rebuildAllUrls( // Optional query hook to narrow the set function (\Illuminate\Database\Eloquent\Builder $q) { $q->where('status', 'published'); }, chunk: 1000 );
- Processes in chunks.
- Calls the same versioning logic as normal saves.
- Does not trigger your model’s
saved()
hooks or cascades (it directly re-syncs).
8) Exceptions you should know
-
ModelUrlContractNotFoundException
Your model must implement
UrlContract
. The trait checks this at boot. -
SlugConflictException
Another model of the same type already uses this slug (in the same collection). Handle this when calling
dispatchSlug()
or when restoring. -
UrlConflictException
Another active model already owns the computed full URL. Can be thrown during saves, cascades, rebuilds, or restore.
Example handling
try { $product->dispatchSlug('macbook-pro-14', 'products'); } catch (\JobMetric\Url\Exceptions\SlugConflictException $e) { // Ask user to pick a different slug }
9) API Reference (trait helpers)
Methods below are provided by
HasUrl
unless otherwise noted.
Slug methods
$product->dispatchSlug(?string $slug, ?string $collection = null): array; // Upserts slug and syncs URL (returns ['ok' => bool, 'data' => SlugResource?]) $product->forgetSlug(?string $collection = null): array; // Soft-deletes the slug row (if collection matches when provided) $product->slug(): array; // Envelope with SlugResource (default collection) $product->slugByCollection(?string $collection = null, bool $mode = false): array|string|null; // Envelope with SlugResource OR just slug string when $mode=true $product->slug; // string|null (accessor) $product->slug_resource; // SlugResource|null (accessor) $product->slug_collection; // string|null (accessor)
URL methods
$product->getActiveFullUrl(): ?string; // Returns current active full URL without recomputing $product->urlHistory(bool $withTrashed = true): \Illuminate\Support\Collection; // Returns Url[] ordered by version asc // Static utilities (on Url model via trait-provided statics) \JobMetric\Url\Models\Url::resolveActiveByFullUrl(string $fullUrl): ?\Illuminate\Database\Eloquent\Model; \JobMetric\Url\Models\Url::resolveRedirectTarget(string $fullUrl): ?string;
Finders
Product::findBySlug(string $slug): ?Product; Product::findBySlugOrFail(string $slug): ?Product; // throws SlugNotFoundException Product::findBySlugAndCollection(string $slug, ?string $collection = null): ?Product; Product::findBySlugAndCollectionOrFail(string $slug, ?string $collection = null): ?Product;
Bulk operations
Product::rebuildAllUrls(?callable $queryHook = null, int $chunk = 500): void;
Cascade control
$product->withoutUrlCascade(callable $fn): mixed; // Temporarily disable descendant refresh inside the callback
10) Real-World Examples
10.1 Category → Product path dependency with cascade
class Category extends Model implements UrlContract { use HasUrl; public function products() { return $this->hasMany(Product::class); } public function getFullUrl(): string { return '/shop/'.$this->slug; } public function getUrlDescendants(): iterable { return $this->products; } } class Product extends Model implements UrlContract { use HasUrl; public function category() { return $this->belongsTo(Category::class); } public function getFullUrl(): string { return '/shop/'.($this->category->slug ?? 'uncategorized').'/'.$this->slug; } } // Change category slug → all children get new versioned URLs $category->dispatchSlug('laptops');
10.2 Changing only the collection
// Same slug, move to a new collection $product->dispatchSlug('mbp-14', 'featured-products'); // Active URL remains, but the Url row’s collection field updates
10.3 Handling a user-entered duplicate slug
try { $product->dispatchSlug(request('slug')); // no collection } catch (\JobMetric\Url\Exceptions\SlugConflictException $e) { return back()->withErrors([ 'slug' => 'This slug is already taken for this type.', ]); }
10.4 301 redirects from old URLs
Combine resolveRedirectTarget()
with a middleware (see §4.3). This protects SEO when URLs change over time.
11) Recommended database indexes
These are already baked into the package’s migrations. If you maintain your own schema, consider the following:
-
slugs
table- Unique composite:
(slugable_type, slugable_id, deleted_at)
- Optional unique composite for multi-collection setups:
(slugable_type, collection, slug, deleted_at)
- Unique composite:
-
urls
table- Unique composite:
(urlable_type, urlable_id, version)
- Index on
full_url
with a filter ondeleted_at
can speed up conflict checks.
- Unique composite:
12) Events
UrlChanged
- Fired after a new active URL row is created.
- Signature:
new UrlChanged(Model&UrlContract $model, ?string $old, string $new, int $version)
Use it to update caches, ping search engines, or trigger webhooks.
13) Testing tips
- When asserting URL changes, check both:
- The active URL row (
deleted_at NULL
, highestversion
). - The soft-deleted previous row for redirection logic.
- The active URL row (
- If you test cascades, ensure child models also implement
UrlContract
.
Fallback Route (Smart URL Resolver)
This package can register a single Laravel fallback route that resolves any unmatched path using the versioned urls
table:
- If there’s an active URL row → it fires an
UrlMatched
event so your app can decide what to return (product page, category page, CMS page, JSON, etc.). - If there’s only a legacy (soft-deleted) match → it issues a 301 redirect to the current canonical URL (SEO-friendly).
- Otherwise → returns a translated 404.
Enabling / Disabling
The fallback is on by default. Control it in config/url.php
:
return [ // Register the fallback router that pipes all unknown paths into FullUrlController 'register_fallback' => true, // Middleware stack for the fallback route (defaults to 'web') 'fallback_middleware' => ['web'], // ... other config ];
The provider wires it for you:
Route::middleware(config('url.fallback_middleware', ['web'])) ->group(function () { Route::fallback(\JobMetric\Url\Http\Controllers\FullUrlController::class) ->name('JobMetric.url.fallback'); });
How it resolves paths
For a request like GET /shop/laptops/mbp-14?color=silver
, the controller builds these candidates and looks them up (most recent first):
shop/laptops/mbp-14
shop/laptops/mbp-14/
/shop/laptops/mbp-14
/shop/laptops/mbp-14/
/
(root special-case for empty paths)
Then it:
- Tries to find an active URL (
deleted_at NULL
, latestversion
). - If not found, checks legacy URLs (soft-deleted) and redirects (301) to the canonical URL of the same model (preserving query string).
- If still nothing, returns 404 (translated:
trans('url::base.exceptions.not_found')
).
The UrlMatched
Event
When an active Url
row is found, the controller emits:
new UrlMatched(Request $request, Url $url)
Useful properties:
$event->request
— the incomingIlluminate\Http\Request
$event->url
— the matchedUrl
row (active)$event->urlable
— the polymorphic model instance (e.g., Product, Category)$event->collection
— optional URL collection string$event->response
— initiallynull
. Your listener must set it via$event->respond($response)
to short-circuit and return the response.
If no listener sets a response, the controller returns 404.
Writing Listeners (Many Ways)
You can wire listeners in the EventServiceProvider, or use Event::listen
at boot time, or register a dedicated invokable class. Below are several patterns with realistic content.
1) Quick inline listener (closure) — Product page
app/Providers/EventServiceProvider.php
use Illuminate\Support\Facades\Event; use JobMetric\Url\Events\UrlMatched; use App\Http\Controllers\ProductController; public function boot(): void { parent::boot(); Event::listen(UrlMatched::class, function (UrlMatched $event) { // Route only Product models here if ($event->urlable instanceof \App\Models\Product) { // Delegate to your controller action $response = app(ProductController::class)->show($event->urlable); $event->respond($response); } }); }
app/Http/Controllers/ProductController.php
namespace App\Http\Controllers; use App\Models\Product; class ProductController extends Controller { public function show(Product $product) { // Render your product page (Blade, Inertia, etc.) return view('shop.product', [ 'product' => $product, 'canonical' => $product->getActiveFullUrl(), ]); } }
2) Another closure — Category page with pagination
use Illuminate\Support\Facades\Event; use JobMetric\Url\Events\UrlMatched; use App\Http\Controllers\CategoryController; Event::listen(UrlMatched::class, function (UrlMatched $event) { if ($event->urlable instanceof \App\Models\Category) { $page = (int) $event->request->query('page', 1); $response = app(CategoryController::class)->show($event->urlable, $page); $event->respond($response); } });
app/Http/Controllers/CategoryController.php
namespace App\Http\Controllers; use App\Models\Category; class CategoryController extends Controller { public function show(Category $category, int $page = 1) { $products = $category->products()->paginate(24, ['*'], 'page', $page); return view('shop.category', [ 'category' => $category, 'products' => $products, ]); } }
3) Invokable listener class — clean separation
app/Listeners/HandleMatchedUrl.php
namespace App\Listeners; use JobMetric\Url\Events\UrlMatched; use App\Http\Controllers\ProductController; use App\Http\Controllers\CategoryController; class HandleMatchedUrl { public function __invoke(UrlMatched $event): void { $model = $event->urlable; if ($model instanceof \App\Models\Product) { $event->respond(app(ProductController::class)->show($model)); return; } if ($model instanceof \App\Models\Category) { $event->respond(app(CategoryController::class)->show($model, (int) $event->request->query('page', 1))); return; } // Fallback: JSON for unknown urlables (optional) $event->respond(response()->json([ 'type' => class_basename($model), 'id' => $model->getKey(), 'url' => $event->url->full_url, ])); } }
app/Providers/EventServiceProvider.php
protected $listen = [ \JobMetric\Url\Events\UrlMatched::class => [ \App\Listeners\HandleMatchedUrl::class, ], ];
4) Listener that does a custom redirect
Use this when you want to override the canonical route entirely.
use Illuminate\Support\Facades\Event; use JobMetric\Url\Events\UrlMatched; Event::listen(UrlMatched::class, function (UrlMatched $event) { $model = $event->urlable; if ($model instanceof \App\Models\Product && $model->is_archived) { // Send archived products to a landing page $event->respond(redirect()->route('shop.archive')); } });
5) Listener returning an API response (JSON)
Event::listen(UrlMatched::class, function (UrlMatched $event) { if ($event->request->wantsJson()) { $event->respond(response()->json([ 'url' => $event->url->full_url, 'collection'=> $event->collection, 'type' => class_basename($event->urlable), 'data' => $event->urlable->toArray(), ])); } });
6) Using route model binding after match (optional pattern)
You can also “bridge” into a named route:
Event::listen(UrlMatched::class, function (UrlMatched $event) { $model = $event->urlable; if ($model instanceof \App\Models\Product) { $event->respond( redirect()->route('products.show', $model) // e.g., /products/{product} ); } });
Security, Middleware & Guards
You can add auth, localization, throttling, etc., by stacking middleware in config/url.php
:
'fallback_middleware' => ['web', 'localize', 'cache.headers:public;max_age=120'],
If a listener must be protected:
Event::listen(UrlMatched::class, function (UrlMatched $event) { if ($event->urlable instanceof \App\Models\AdminPage) { if (!$event->request->user()?->can('view-admin-pages')) { $event->respond(response('Forbidden', 403)); return; } $event->respond(view('admin.page', ['page' => $event->urlable])); } });
Redirects from Legacy URLs (Built-in)
When a previously active URL becomes obsolete (soft-deleted), the fallback will automatically 301 to the model’s current canonical URL. Query strings are preserved:
- Request:
/old/path?ref=fb
- Redirect:
301 → /new/path?ref=fb
You don’t need to configure anything for this behavior; it’s baked into the controller.
Testing Recipes
1) Exactly one event dispatched per request
public function test_one_event_dispatched_per_request(): void { \Illuminate\Support\Facades\Event::spy(); // Provide a listener that returns a response, so the fallback doesn't 404 \Illuminate\Support\Facades\Event::listen(\JobMetric\Url\Events\UrlMatched::class, function ($event) { $event->respond(response('OK', 200)); }); // Prepare a model that owns '/a' \JobMetric\Url\Tests\Stubs\Models\Category::factory()->setUrl('a')->create(); $this->get('/a')->assertOk()->assertSee('OK'); \Illuminate\Support\Facades\Event::assertDispatched(\JobMetric\Url\Events\UrlMatched::class, 1); }
If you see a
404
in the test, it usually means no listener set a response. Make sure your listener calls$event->respond(...)
.
2) Legacy redirect
public function test_legacy_redirect_to_canonical(): void { // Create a Product with initial slug, then change it to create a legacy URL $product = \App\Models\Product::factory()->create(); $product->dispatchSlug('old-path'); $product->dispatchSlug('new-path'); $this->get('/old-path')->assertRedirect('/new-path'); }
3) JSON response when Accept: application/json
public function test_json_response_via_listener(): void { \Illuminate\Support\Facades\Event::listen(\JobMetric\Url\Events\UrlMatched::class, function ($event) { if ($event->request->wantsJson()) { $event->respond(response()->json(['ok' => true, 'id' => $event->urlable->getKey()])); } }); $model = \App\Models\Product::factory()->create(); $model->dispatchSlug('api-item'); $this->getJson('/api-item') ->assertOk() ->assertJson(['ok' => true]); }
Troubleshooting
-
I get 404 on a known URL
- Ensure a listener sets a response via
$event->respond(...)
. - Confirm the URL row is active (not soft-deleted) and the model is present.
- Check that
register_fallback
istrue
and the middleware group includesweb
(or your session/localization needs).
- Ensure a listener sets a response via
-
Infinite redirects
- Don’t redirect the same matched path to itself.
- If you redirect into another path that also resolves to the same model, consider returning the view instead of chaining redirects.
-
Wrong page returned
- Check your “router” logic in the listener (e.g.,
instanceof
checks). - Verify your model implements
UrlContract
and thatgetFullUrl()
computes the intended canonical path.
- Check your “router” logic in the listener (e.g.,
Example: Full Setup Summary
- Models implement
UrlContract
and useHasUrl
.
class Product extends Model implements \JobMetric\Url\Contracts\UrlContract { use \JobMetric\Url\HasUrl; public function category() { return $this->belongsTo(Category::class); } public function getFullUrl(): string { $cat = $this->category?->slug ?? 'uncategorized'; return "/shop/{$cat}/{$this->slug}"; } }
- Assign slugs (versioned URL is created automatically).
$product->dispatchSlug('mbp-14', 'products');
- Register listeners to render pages.
Event::listen(\JobMetric\Url\Events\UrlMatched::class, function ($event) { $model = $event->urlable; if ($model instanceof \App\Models\Product) { $event->respond(view('shop.product', ['product' => $model])); return; } if ($model instanceof \App\Models\Category) { $event->respond(view('shop.category', [ 'category' => $model, 'products' => $model->products()->paginate(24), ])); return; } });
- Enjoy free 301 redirects for old paths (no extra code).
With this fallback + event pattern, you get one URL entry point that can render anything (products, categories, blogs, CMS pages) based on the database — while preserving SEO via automatic legacy redirects, and keeping your controllers and routes tidy.
Validation: SlugExistRule
SlugExistRule
validates that a slug is unique for a given model class and optional collection, ignoring soft-deleted rows and optionally excluding the current record (useful for update forms).
Despite the name, it enforces
uniqueness
(it fails if a matching active slug already exists). It alsonormalizes
the incoming value exactly like the trait does:Str::slug(trim($value))
and limits it to 100 chars before checking.
Constructor
new \JobMetric\Url\Rules\SlugExistRule( string $className, // Eloquent model class that uses HasUrl ?string $collection = null, // Optional collection; '' is treated as null ?int $objectId = null // Current model ID to exclude (on update) )
- $className: your Eloquent model FQCN (e.g.,
App\Models\Product::class
). - $collection: pass
null
for the default collection; pass a non-empty string to scope by collection. - $objectId: exclude the current record when updating so the user can keep the same slug.
The rule queries the slugs
table with:
slugable_type = $className
collection = $collection
(orNULL
if omitted)deleted_at IS NULL
(only active rows)slug = normalized($value)
slugable_id != $objectId
(when provided)
If a row exists, validation fails with trans('url::base.rule.exist')
.
Why use this rule?
- Same normalization as
HasUrl
→ your validation view matches what will be stored. - Active-only uniqueness → allows reusing slugs from soft-deleted records.
- Update-safe → exclude the current record by ID.
- Prevents late exceptions → catch conflicts before calling
dispatchSlug()
.
Common Recipes
1) Create request: simple uniqueness in a fixed collection
use Illuminate\Foundation\Http\FormRequest; use JobMetric\Url\Rules\SlugExistRule; use App\Models\Product; class StoreProductRequest extends FormRequest { public function rules(): array { return [ 'name' => ['required', 'string', 'max:255'], 'slug' => [ 'required', 'string', 'max:100', new SlugExistRule(Product::class, 'products'), ], // optional: send a collection explicitly // 'slug_collection' => ['nullable', 'string', 'max:50'], ]; } }
Tip: Set
max:100
to align with internal normalization (Str::limit(..., 100)
).
2) Update request: exclude current record
use Illuminate\Foundation\Http\FormRequest; use JobMetric\Url\Rules\SlugExistRule; use App\Models\Product; class UpdateProductRequest extends FormRequest { public function rules(): array { $productId = $this->route('product')?->id ?? null; // route-model binding return [ 'name' => ['sometimes', 'string', 'max:255'], 'slug' => [ 'sometimes', 'string', 'max:100', new SlugExistRule(Product::class, 'products', $productId), ], ]; } }
If the slug did not change, the rule allows it (because it excludes $productId
).
3) Dynamic collection from request (or model type)
use JobMetric\Url\Rules\SlugExistRule; use App\Models\Category; public function rules(): array { $collection = $this->input('slug_collection'); // '' will be treated as null by the rule return [ 'title' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:100', new SlugExistRule(Category::class, $collection)], 'slug_collection' => ['nullable', 'string', 'max:50'], ]; }
If you omit the collection entirely when you later call dispatchSlug()
, the trait will fall back to your model’s getSlugCollectionDefault()
or its type
attribute (see the HasUrl docs).
4) Programmatic validation (no FormRequest)
use Illuminate\Support\Facades\Validator; use JobMetric\Url\Rules\SlugExistRule; use App\Models\Product; $data = ['slug' => 'MacBook Pro 14']; $rule = new SlugExistRule(Product::class, 'products'); $validator = Validator::make($data, [ 'slug' => ['required', 'string', 'max:100', $rule], ]); $validator->validate(); // throws if collision exists
5) Nested payloads or admin panels
return [ 'product' => ['required', 'array'], 'product.slug' => [ 'required', 'string', 'max:100', new SlugExistRule(\App\Models\Product::class, $this->input('product.slug_collection')), ], ];
Error Messages
By default, failures use trans('url::base.rule.exist')
. You can override per-field:
public function messages(): array { return [ 'slug.required' => 'Please enter a slug.', // Override the package’s message for this field: 'slug.*' => 'This slug is already in use for this type/collection.', ]; }
Or customize the translation key in resources/lang/{locale}/url/base.php
:
return [ 'rule' => [ 'exist' => 'This slug is already taken.', ], ];
End-to-End Example
Controller (simplified):
public function store(StoreProductRequest $request) { $product = \App\Models\Product::create([ 'name' => $request->input('name'), ]); // Persist slug with an optional collection $product->dispatchSlug( $request->input('slug'), $request->input('slug_collection') // may be null ); return redirect()->to($product->getActiveFullUrl()); }
- The request ensures pre-flight uniqueness with
SlugExistRule
. dispatchSlug()
will upsert theslugs
row and sync the versioned URL.
Testing the Rule
1) It fails when another active slug exists
use JobMetric\Url\Rules\SlugExistRule; public function test_slug_rule_blocks_duplicates(): void { $p1 = \App\Models\Product::factory()->create(); $p1->dispatchSlug('existing', 'products'); $rule = new SlugExistRule(\App\Models\Product::class, 'products'); $this->expectException(\Illuminate\Validation\ValidationException::class); validator(['slug' => 'existing'], [ 'slug' => ['required', 'string', 'max:100', $rule], ])->validate(); }
2) It allows reusing a soft-deleted slug
public function test_slug_rule_ignores_soft_deleted(): void { $p = \App\Models\Product::factory()->create(); $p->dispatchSlug('recyclable', 'products'); $p->delete(); // soft-delete => slug row soft-deleted $rule = new SlugExistRule(\App\Models\Product::class, 'products'); $this->assertTrue( validator(['slug' => 'recyclable'], [ 'slug' => ['required', 'string', 'max:100', $rule], ])->passes() ); }
3) It allows keeping the same slug on update
public function test_slug_rule_excludes_current_id_on_update(): void { $p = \App\Models\Product::factory()->create(); $p->dispatchSlug('keep-me', 'products'); $rule = new SlugExistRule(\App\Models\Product::class, 'products', $p->id); $this->assertTrue( validator(['slug' => 'keep-me'], [ 'slug' => ['required', 'string', 'max:100', $rule], ])->passes() ); }
Pitfalls & Tips
- Do not rely on
unique
: database rules for slugs: they won’t match this package’s normalization and soft-delete semantics. - Match the 100-char limit in your form rules. The rule internally truncates to 100; adding
max:100
gives clear UX. - Empty values are ignored by the rule (let
required|string
handle presence). - Collection
''
is treated asnull
by the rule for convenience. - This rule validates pre-flight; conflicts can still be thrown later by
dispatchSlug()
if something changes between validation and save (rare, but possible under race conditions). Handle exceptions likeSlugConflictException
defensively in your save flow if needed.
With SlugExistRule
in place, your forms catch slug collisions before calling dispatchSlug()
, keeping user feedback fast and precise while staying perfectly aligned with how the package stores and normalizes slugs.
Contributing
Thank you for considering contributing to the Laravel Url! The contribution guide can be found in the CONTRIBUTING.md.
License
The MIT License (MIT). Please see License File for more information.