Skip to content

khalyomede/laravel-eloquent-uuid-slug

Repository files navigation

Laravel Eloquent UUID slug

Summary

About

By default, when getting a model from a controller using Route Model Binding, Laravel will try to find a model using the parameter in your route, and associate it to the default identifier of the related table (most of the time, this is the "id" key).

// routes/web.php

use App\Models\Cart;
use Illuminate\Support\Facades\Route;

// --> What you see
Route::get("/cart/{cart}", function(Cart $cart) {
  // $cart ready to be used
});

// --> What happens behind the scene
Route::get("/cart/{cart}", function(string $identifier) {
  $cart = Cart::findOrFail($identifier);

  // $cart ready to be used
});

This means if you offer the possibility to view your cart, you will expose the route /cart/12 for example. This is not ideal in terms of security because you now expose your cart database identifier, and if you forgot or made a mistake into your cart's policy, a malicious user can access the cart of other users (/cart/41).

In this context UUID are very useful because:

  • They offer a good way to create random, hard to predict identifiers
  • Can be manually generated from the code
  • Are not likely to collide

The best scenarios would be to expose this uuid instead of your database auto incremented identifier, like /cart/e22b86bcb8e24cfea13856a0766bfef2.

The goal of this package is to simplify at best this task for you.

Features

  • Provide a trait to configure your Route Model Binding to use a slug column
  • Provide an helper to create the slug column on your migration, according to your configuration
  • Provide a scope to find your model by the slug column
  • Allow you to customize the name of the slug column

Requirements

Installation

1. Install the package

composer require khalyomede/laravel-eloquent-uuid-slug

2. Setup your model

On the model of your choice, use the Sluggable trait.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Khalyomede\EloquentUuidSlug\Sluggable;

class Cart extends Model
{
  use Sluggable;
}

3. Add the slug column in your migration

The Sluggable trait offers the method Sluggable::addSlugColumn() to make this step a breeze for you.

use App\Models\Cart;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

final class CreateCartsTable extends Migration
{
  public function up(): void
  {
    Schema::create('carts', function (Blueprint $table): void {
      $table->id();
      $table->string('name');

      Cart::addSlugColumn($table);

      $table->timestamps();
    });
  }

  public function down(): void
  {
    Schema::drop('carts');
  }
}

If you do not want the Sluggable::addSlugColumn(Blueprint) to add SQL constraints (unique index and non nullable column), use its counterpart method Sluggable::addUnconstrainedSlugColumn(Blueprint).

use App\Models\Cart;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

final class CreateCartsTable extends Migration
{
  public function up(): void
  {
    Schema::create('carts', function (Blueprint $table): void {
      $table->id();
      $table->string('name');

      Cart::addUnconstrainedSlugColumn($table);

      $table->timestamps();
    });
  }

  public function down(): void
  {
    Schema::drop('carts');
  }
}

Examples

1. Configure the slug column name

By default the Sluggable trait will assume the name of the slug column is slug. Here is how to provide one that you prefer.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Khalyomede\EloquentUuidSlug\Sluggable;

class Cart extends Model
{
  use Sluggable;

  public function slugColumn(): string
  {
    return 'code';
  }
}

2. Use dashes for the generated UUID

By default, the Sluggable trait will configure the UUID generator to remove dashes, to help make shorter URLs. If you prefer to keep them, here is how you can do it.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Khalyomede\EloquentUuidSlug\Sluggable;

class Cart extends Model
{
  use Sluggable;

  public function slugWithDashes(): bool
  {
    return true;
  }
}

3. Custom route model binding for specific routes

By default, all your models that use the Sluggable trait will retreive their model using the slug column when performing Route Model Binding.

If you would like to bypass it for specific routes, you can customize the column used to retreive your model occasionally.

For example, this is how to retreive your Cart model using its id for a specific route.

// routes/web.php

use App\Models\Cart;
use Illuminate\Support\Facades\Route;

// --> What you see
Route::get("/cart/{cart:id}", function(Cart $cart) {
  // $cart ready to be used
});

As a final resort, if this method does not work, you can always fallback to get the raw data from your route, and perform fetching your model yourself:

// routes/web.php

use App\Models\Cart;
use Illuminate\Support\Facades\Route;

// --> What you see
Route::get("/cart/{cart}", function(string $identifier) {
  $cart = Cart::findOrFail($identifier);

  // $cart ready to be used
});

4. Customize the slug column in your migration

You can use all the available column modifiers right after calling the method Sluggable::addSlugColumn(), to re-order the column or add some comments for example.

use App\Models\Cart;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

final class CreateCartsTable extends Migration
{
  public function up(): void
  {
    Schema::create('carts', function (Blueprint $table): void {
      $table->id();
      $table->string('name');

      Cart::addSlugColumn($table)
        ->after('name')
        ->comment('Auto-generated by a package.');

      $table->timestamps();
    });
  }

  public function down(): void
  {
    Schema::drop('carts');
  }
}

5. Retreive a model by its slug

To help you manually fetching a model by its slug, you can use the Sluggable::scopeWithSlug() scope to do it. It follows your configuration, so no matter how you named your slug column it will still work.

// routes/web.php

use App\Models\Cart;
use Illuminate\Support\Facades\Route;

Route::get("/cart/{cart}", function(string $identifier) {
  $cart = Cart::withSlug($identifier)->firstOrFail();

  // $cart ready to be used
});

The Sluggable::findBySlug(), Sluggable::findBySlugOrFail(), Sluggable::firstBySlug() or Sluggable::firstBySlugOrFail() methods also exist as a shorthand:

// routes/web.php

use App\Models\Cart;
use Illuminate\Support\Facades\Route;

Route::get("/cart/{cart}", function(string $identifier) {
  $cart = Cart::findBySlugOrFail($identifier);
  $cart = Cart::findBySlug($identifier);
  $cart = Cart::where("id", ">=", 1)->firstBySlug($identifier);
  $cart = Cart::where("id", ">=", 1)->firstBySlugOrFail($identifier);

  // $cart ready to be used
});

6. Dropping the slug column

You can use Sluggable::dropSlugColumn(Blueprint) when you want to drop only the slug column on an existing table. Please follow the complete instructions on the "down" method since there is some gotchas to deal with.

use App\Models\Cart;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

final class DropSlugColumnOnCartsTable extends Migration
{
  public function up(): void
  {
    Schema::create('carts', function (Blueprint $table): void {
      Cart::dropSlugColumn($table);
    });
  }

  public function down(): void
  {
    Schema::table('posts', function (Blueprint $table): void {
      Cart::addUnconstrainedSlugColumn($table);
    });

    Schema::table('posts', function (Blueprint $table): void {
      Cart::fillEmptySlugs();
      Cart::constrainSlugColumn($table);
    });
  }
}

7. Validate a value exists by slug

You can validate a model exists by the slug column you defined. This is equivalent to calling the existing "exists" rule:

"post_id" => "exists:posts,slug"

But without having to manually specify the slug column (it is fetched automatically according to wether you customized the name or not).

// app/Http/Controllers/PostController.php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Support\Facades\Validator;
use Khalyomede\EloquentUuidSlug\Rules\ExistsBySlug;

class PostController extends Controller
{
  public function store(Request $request)
  {
    $validator = Validator::make($request->all(), [
      "post_id" => ["required", new ExistsBySlug(Post::class)],
    ]);

    // ...
  }
}

Compatibility table

The table below shows the compatibility across Laravel, PHP and this package current version. For the compatibility regarding this package previous version, please browse another tag.

Laravel version PHP version Compatible
10.* 8.3.*
10.* 8.2.*
10.* 8.1.*
9.* 8.2.*
9.* 8.1.*
9.* 8.0.*
8.* 8.2.*
8.* 8.1.*
8.* 8.0.*
8.* 7.4.*
8.* 7.3.*
7.x *

To counter-check these results, you can use the Docker containers (see docker-compose.yml file) to run the tests described in the Tests section.

Alternatives

I created this package mostly to practice creating a tested laravel package, and toying with my first Github Workflow. There is some others high quality packages out there so make sure to take a look at them!

Tests

composer run test
composer run analyse
composer run check
composer run lint
composer run scan
composer run updates

Or

composer run all