Search code examples
laraveleloquentslug

Laravel eloquent-sluggable package erroring instead of creating unique slug with increment


I asked a question earlier related to the https://github.com/cviebrock/eloquent-sluggable package surrounding the ability to scope slugs to parent models. Got that all working. Now I am running into a different issue.

I have a User model that has, among other attributes, the following attributes:

id
role_id
first_name
last_name
name
slug

...and others

I am trying to generate a slug for this object. As long as no conflicting slug already exists for the User model, it works just fine and will generate one for me upon save.

However, say i already have a user with these attributes:

role_id = 1
first_name = John
last_name = Smith
name = John Smith
slug = john-smith

If i try and save another John Smith, i get this error:

Illuminate\Database\Eloquent\MissingAttributeException^ {#2420
  #message: "The attribute [role_id] either does not exist or was not retrieved for model [App\Models\User]."
  #code: 0
  #file: "/var/www/html/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php"
  #line: 475
  trace: {
    /var/www/html/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php:475 { …}
    /var/www/html/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php:455 { …}
    /var/www/html/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php:2226 { …}
    /var/www/html/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Relations/BelongsTo.php:378 { …}
    /var/www/html/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Relations/BelongsTo.php:133 { …}
    /var/www/html/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Relations/BelongsTo.php:116 { …}
    /var/www/html/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:776 { …}
    /var/www/html/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:754 { …}
    /var/www/html/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:722 { …}
    /var/www/html/vendor/cviebrock/eloquent-sluggable/src/Services/SlugService.php:379 { …}
    /var/www/html/vendor/cviebrock/eloquent-sluggable/src/Services/SlugService.php:278 { …}
    /var/www/html/vendor/cviebrock/eloquent-sluggable/src/Services/SlugService.php:89 { …}
    /var/www/html/vendor/cviebrock/eloquent-sluggable/src/Services/SlugService.php:44 { …}
    /var/www/html/vendor/cviebrock/eloquent-sluggable/src/SluggableObserver.php:81 { …}
    /var/www/html/vendor/cviebrock/eloquent-sluggable/src/SluggableObserver.php:53 { …}
    /var/www/html/vendor/laravel/framework/src/Illuminate/Events/Dispatcher.php:478 { …}
    /var/www/html/vendor/laravel/framework/src/Illuminate/Events/Dispatcher.php:286 { …}
    /var/www/html/vendor/laravel/framework/src/Illuminate/Events/Dispatcher.php:266 { …}
    /var/www/html/vendor/laravel/framework/src/Illuminate/Events/Dispatcher.php:232 { …}
    /var/www/html/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/HasEvents.php:188 { …}

It SHOULD be generating the slug john-smith-2 per my setup. Where can i start to diagnose this?

Here are the relevant parts of my User model:

<?php

namespace App\Models;

use Cviebrock\EloquentSluggable\Sluggable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use function Illuminate\Events\queueable;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;

class User extends Authenticatable implements JWTSubject
{
    use HasFactory;
    use HasProfilePhoto;
    use Notifiable;
    use TwoFactorAuthenticatable;
    use SoftDeletes;
    use Sluggable;

    protected $guarded = ['id'];

    protected $with = ['role'];

    /**
     * The attributes that should be hidden for arrays.
     *
     * @var array
     */
    protected $hidden = [
        'password',
        'remember_token',
        'two_factor_recovery_codes',
        'two_factor_secret',
    ];

    /**
     * The attributes that should be cast to native types.
     *
     * @var array
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
    ];

    /**
     * The accessors to append to the model's array form.
     *
     * @var array
     */
    protected $appends = [
        'profile_photo_url',
        'name',
    ];

    /**
     * Return the sluggable configuration array for this model.
     */
    public function sluggable(): array
    {
        return [
            'slug' => [
                'source' => 'name',
            ],
        ];
    }

    /**
     * Get the route key for the model.
     *
     * @return string
     */
    public function getRouteKeyName(): string
    {
        return 'slug';
    }

    public function role()
    {
        return $this->belongsTo(Role::class);
    }

    .....
}

And here is my sluggalbe config:

<?php

return [

    /**
     * What attributes do we use to build the slug?
     * This can be a single field, like "name" which will build a slug from:
     *
     *     $model->name;
     *
     * Or it can be an array of fields, like ["name", "company"], which builds a slug from:
     *
     *     $model->name . ' ' . $model->company;
     *
     * If you've defined custom getters in your model, you can use those too,
     * since Eloquent will call them when you request a custom attribute.
     *
     * Defaults to null, which uses the toString() method on your model.
     */
    'source' => null,

    /**
     * The maximum length of a generated slug.  Defaults to "null", which means
     * no length restrictions are enforced.  Set it to a positive integer if you
     * want to make sure your slugs aren't too long.
     */
    'maxLength' => null,

    /**
     * If you are setting a maximum length on your slugs, you may not want the
     * truncated string to split a word in half.  The default setting of "true"
     * will ensure this, e.g. with a maxLength of 12:
     *
     *   "my source string" -> "my-source"
     *
     * Setting it to "false" will simply truncate the generated slug at the
     * desired length, e.g.:
     *
     *   "my source string" -> "my-source-st"
     */
    'maxLengthKeepWords' => true,

    /**
     * If left to "null", then use the cocur/slugify package to generate the slug
     * (with the separator defined below).
     *
     * Set this to a closure that accepts two parameters (string and separator)
     * to define a custom slugger.  e.g.:
     *
     *    'method' => function( $string, $sep ) {
     *       return preg_replace('/[^a-z]+/i', $sep, $string);
     *    },
     *
     * Otherwise, this will be treated as a callable to be used.  e.g.:
     *
     *    'method' => array('Str','slug'),
     */
    'method' => null,

    /**
     * Separator to use when generating slugs.  Defaults to a hyphen.
     */
    'separator' => '-',

    /**
     * Enforce uniqueness of slugs?  Defaults to true.
     * If a generated slug already exists, an incremental numeric
     * value will be appended to the end until a unique slug is found.  e.g.:
     *
     *     my-slug
     *     my-slug-1
     *     my-slug-2
     */
    'unique' => true,

    /**
     * If you are enforcing unique slugs, the default is to add an
     * incremental value to the end of the base slug.  Alternatively, you
     * can change this value to a closure that accepts three parameters:
     * the base slug, the separator, and a Collection of the other
     * "similar" slugs.  The closure should return the new unique
     * suffix to append to the slug.
     */
    'uniqueSuffix' => null,

    /**
     * What is the first suffix to add to a slug to make it unique?
     * For the default method of adding incremental integers, we start
     * counting at 2, so the list of slugs would be, e.g.:
     *
     *   - my-post
     *   - my-post-2
     *   - my-post-3
     */
    'firstUniqueSuffix' => 2,

    /**
     * Should we include the trashed items when generating a unique slug?
     * This only applies if the softDelete property is set for the Eloquent model.
     * If set to "false", then a new slug could duplicate one that exists on a trashed model.
     * If set to "true", then uniqueness is enforced across trashed and existing models.
     */
    'includeTrashed' => false,

    /**
     * An array of slug names that can never be used for this model,
     * e.g. to prevent collisions with existing routes or controller methods, etc..
     * Defaults to null (i.e. no reserved names).
     * Can be a static array, e.g.:
     *
     *    'reserved' => array('add', 'delete'),
     *
     * or a closure that returns an array of reserved names.
     * If using a closure, it will accept one parameter: the model itself, and should
     * return an array of reserved names, or null. e.g.
     *
     *    'reserved' => function( Model $model) {
     *      return $model->some_method_that_returns_an_array();
     *    }
     *
     * In the case of a slug that gets generated with one of these reserved names,
     * we will do:
     *
     *    $slug .= $separator + "1"
     *
     * and continue from there.
     */
    'reserved' => null,

    /**
     * Whether to update the slug value when a model is being
     * re-saved (i.e. already exists).  Defaults to false, which
     * means slugs are not updated.
     *
     * Be careful! If you are using slugs to generate URLs, then
     * updating your slug automatically might change your URLs which
     * is probably not a good idea from an SEO point of view.
     * Only set this to true if you understand the possible consequences.
     */
    'onUpdate' => true,

    /**
     * If the default slug engine of cocur/slugify is used, this array of
     * configuration options will be used when instantiating the engine.
     */
    'slugEngineOptions' => [],

];

Any advice is greatly appreciated!


Solution

  • Based on the discussion, and checking the query of sluggalbe package, the issue you are facing is a bug related to the https://github.com/cviebrock/eloquent-sluggable package.

    It seem you cannot use the package in combination to $with relationships.

    The query used by the package is:

            $results = $query->select([$attribute, $this->model->getQualifiedKeyName()])
                ->get()
                ->toBase();
    
            // key the results and return
            return $results->pluck($attribute, $this->model->getKeyName());
    

    As you can see its selecting 2 columns, in your case the users.name column and the users.id column when returning a collection of Eloquent models.

    However laravel internally also tries to append the $with relationship when retrieving the models, but since there are only 2 columns it cannot create the right query since it needs the missing role_id column for the role relationship and hence the Exception.

    You can file a bug on eloquent-sluggable regarding the issue.

    I suppose they used a selective select in the query to reduce memory by avoiding returning a lot of data, however this brakes the usage of $with relationships.