Laravel Bug: Eloquent firstOrCreate() Broken With $casts Model Attribute

There's a bug with Eloquent's firstOrCreate() function where it doesn't work with the $casts attribute on the model; it will always either insert a new instance into the database or give an error similar to the following:

[Illuminate\Database\QueryException] 
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'key' for key 'PRIMARY'

There's more information, and discussion, on this bug on the github issue page. This bug was discovered in Laravel 5.4, but it seems as though it persists in 5.5 too. More information about attribute casting can be found in the Laravel documentation.

(If you want to just see the solution, skip down to TL;DR)

The Scenario

Take the following example where you have a Role model and you want to seed your database with some initial roles. Here's the model:

class Role extends Model
{
/**
* The attributes that should be casted to native types.
*
* @var array
*/
protected $casts = [
'label' => 'array',
];
}

And then we have the following seed class:

class RolesTableSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Role::firstOrCreate([
'name' => 'admin',
'label' => ["en" => "Admin", "fr" => "Admin"],
]);
Role::firstOrCreate([
'name' => 'user',
'label' => ["en" => "User", "fr" => "Utilisateur"],
]);
}
}

We have a JSON/TEXT field on the roles table called label which we're using to store an associative array of translations for the role name. Doing this allows us to access the $role->label attribute as an array, rather than as the JSON string. Also we can update the attribute as an array too.

// We can update the attribute as an array.
$role->label = ['en' => 'User', 'fr' => 'Utilisateur'];
// Which stores that as the following in the database: '{"en":"User","fr":"Utilisateur"}'

// And we can access $role->label as an array too.
echo $role->label['fr'];
// will print 'Utilisateur'

The Bug

Basically firstOrCreate works by searching for a row in the database that matches the array of values passed through. If a match doesn't exist, it will insert a row. If it finds a match for the data, it won't insert a new row.

The bug is that this search matching doesn't consider that an attribute has been casted in the $casts property on the model and so the array passed through, won't match the string stored in the database.

['en' => 'User', 'fr' => 'Utilisateur'] != '{"en":"User","fr":"Utilisateur"}'

However, when the insert is performed, it does consider the $casts property.

This means that whenever you rerun the seed, either a new row is inserted, or we get the Integrity constraint violation MySQL error because it attempts to insert a new row which violates a primary key or unique index.

This affects dates too as well as arrays, but might work for different cast types.

TL;DR - A Couple of Solutions

You have a couple of options to fix this.

The first is to check for the presence of a row in the database before inserting it:

if (!Role::whereName('user')->first()) {
Role::create([
'name' => 'user',
'label' => ["en" => "User", "fr" => "Utilisateur"],
]);
}

This is great if you have a small number of fields with which you can identify a row, but you might end up building a large query in the if statement if you have many fields that define a unique row.

Another more succinct option, is to use the updateOrCreate Eloquent function.

Role::updateOrCreate(
[
'name' => 'user',
],
[
'label' => ["en" => "User", "fr" => "Utilisateur"],
]
);

Where the first array is of the standard attributes and the second is the array of your casted attributes. updateOrCreate works in a similar way to firstOrCreate, except it searches on the first array. If the row isn't found, it inserts the attributes from both arrays. Otherwise the found row is updated wth the attributes in the second array. More information on these functions can be found in the Laravel documentation.

The downside to this is that the row is updated every time, which will update the updated_at attribute if you're using timestamps and will be slower, but that shouldn't matter in seeds too much.

NOTE: You also need to ensure that the row can be identified by the first array and not any properties in the second array. Otherwise the wrong row in the database might be updated. A way to always ensure that's the case is to specify a primary key or unique index column/s.

Posted on Nov 09, 2017

Tagged

Discuss This

blog comments powered by Disqus