A pattern that I often encounter when developing forms is a form containing a hasMany
relationship. For example, a recipe form with the ability to add ingredients.
Laravel makes it easy to validate a form like this, thanks to nested array validation. A little less obvious is creating, updating and deleting the related models (in this case ingredients).
In this article I'm going to explain you how to make a macro that allows you to call createUpdateOrDelete()
on Eloquent hasMany
relationships.
$recipe
->ingredients()
->createUpdateOrDelete($data);
Feel free to skip to the macro immediately.
Before dealing with the macro, let's think about how we would tackle this problem.
The first form submission of our recipe form is straightforward. All the ingredients are new, so we could just create them.
$recipe->ingredients()->createMany([
['name' => 'Pecorino cheese'],
['name' => 'Spaghetti'],
['name' => 'Pancetta'],
['name' => 'Cream'],
]);
But that wouldn't work for the subsequent form submissions as these can lead to different scenarios. Ingredients could be changed, missing or completely new.
# First request
recipe:
title: Spaghetti carbonara
ingredients:
- name: Pecorino cheese
- name: Spaghetti
- name: Pancetta
- name: Cream # Missing in second request
# Second request
recipe:
title: Spaghetti carbonara
ingredients:
- id: 1
name: Pecorino cheese
- id: 2
name: Spaghetti
- id: 3
name: Guanciale # Changed
- name: Salt & pepper # New
- name: Eggs # New
As you can see in the hypothetical request data, the second request contains the id's of the existing ingredients. With this information we can determine what ingredients should be created, updated or deleted.
Ingredients with an id should be updated and the ones without an id created. For these two cases we can use Laravel's updateOrCreate
or upsert
methods. They both have their advantages and disadvantages.
updated_at
timestamps.This is how our solution would work with updateOrCreate
.
$ingredients = collect([
['id' => 1, 'name' => 'Pecorino cheese'],
['id' => 2, 'name' => 'Spaghetti'],
['id' => 3, 'name' => 'Guanciale'],
['id' => null, 'name' => 'Salt & pepper'],
['id' => null, 'name' => 'Eggs'],
]);
$ingredients->each(function ($data) use ($recipe) {
$recipe->ingredients()->updateOrCreate([
'id' => $data['id'] ?? null,
], $data);
});
We loop through all the ingredients and pass the data to updateOrCreate
. The first argument contains the data that uniquely identifies a record (in this case the id
). The second argument contains the data that you want to update when a record was found. If the id is null, no record will be found and a new ingredient will be created.
If you prefer upsert
over updateOrCreate
, here's an example using upsert
.
$recipe->ingredients()->upsert([
['id' => 1, 'recipe_id' => 1, 'name' => 'Pecorino cheese'],
['id' => 2, 'recipe_id' => 1, 'name' => 'Spaghetti'],
['id' => 3, 'recipe_id' => 1, 'name' => 'Guanciale'],
['id' => null, 'recipe_id' => 1, 'name' => 'Salt & pepper'],
['id' => null, 'recipe_id' => 1, 'name' => 'Eggs'],
], ['id']);
The first argument contains all the ingredient values (note that we have to manually include the foreign key recipe_id
). The second argument lists the column(s) that uniquely identify the records.
âšī¸ We could add a third argument to provide an array with the columns that should be updated when a matching record is found. We want to update all columns, so we can omit this argument.
For the deleting of the missing ingredients, we can use whereNotIn
combined with delete
. The delete
has to happen before the updateOrCreate
or upsert
, otherwise the newly added records will be deleted as well.
// delete
$recipe
->ingredients()
->whereNotIn('id', [1, 2, 3])
->delete();
// updateOrCreate or upsert
// ...
Let's now combine everything we've learned into one macro.
Let's define a macro in the AppServiceProvider
boot()
method. The name of the macro will be createUpdateOrDelete
and it will accept iterable $records
. An iterable could be an array, or even a Laravel collection.
// AppServiceProvider.php
use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Support\Macros\CreateUpdateOrDelete;
// Inside of the boot() method.
HasMany::macro('createUpdateOrDelete', function (iterable $records) {
/** @var HasMany */
$hasMany = $this;
return (new CreateUpdateOrDelete($hasMany, $records))();
});
Laravel automatically binds $this
to the HasMany
instance. We'll use that together with the records to create an instance of our macro class. We can call the CreateUpdateOrDelete
instance like a function, since we're going to make it invokable.
First, let's make sure that the records (ingredients) actually belong to the parent model (recipe). We don't want people passing around id's they don't have access to (thanks Marek for noticing this). We will filter out unwanted records in the constructor.
namespace App\Support\Macros;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
class CreateUpdateOrDelete
{
protected $query;
protected $records;
public function __construct(HasMany $query, iterable $records)
{
// id (ingredient table)
$relatedKeyName = $query->getRelated()->getKeyName();
$allowedRecordIds = $query->pluck($relatedKeyName);
$this->query = $query;
$this->records = collect($records)->filter(
function ($record) use ($relatedKeyName, $allowedRecordIds) {
$id = $record[$relatedKeyName] ?? null;
return $id === null || $allowedRecordIds->contains($id);
}
);
}
}
In the __invoke
method we wrap the delete and updateOrCreate methods in a transaction. Either everything fails, or everything succeeds. And as mentioned before, we execute the delete before the upsert.
public function __invoke()
{
DB::transaction(function () {
$this->deleteMissingRecords();
$this->updateOrCreateRecords();
// Or $this->upsertRecords();
});
}
The implementation of deleteMissingRecords
is fairly similar to what we've seen before, but there are a couple of differences. Instead of hardcoding the id
we grab the key name from the related table (ingredients). Once we have the key name, we can pluck the id's from the records and filter out any empty values.
protected function deleteMissingRecords()
{
// id (ingredient table)
$recordKeyName = $this->query->getRelated()->getKeyName();
$existingRecordIds = $this->records
->pluck($recordKeyName)
->filter();
(clone $this->query)
->whereNotIn($recordKeyName, $existingRecordIds)
->delete();
}
The delete is executed on a clone of the query. The clone is necessary because otherwise the whereNotIn
clause would still be present on the query when we use it for with updateOrCreate
or upsert
.
Here's the implementation for the updateOrCreateRecords
method. Just like with the deleteMissingRecords
method, we replace the hardcoded id
column. We clone the query again because updateOrCreate
will also add a where clause to the query. We don't want the previous where clause to be present when we arrive in the next iteration of the foreach loop.
protected function updateOrCreateRecords() {
// id (ingredient table)
$recordKeyName = $this->query->getRelated()->getKeyName();
$this->records->each(function ($record) use ($recordKeyName) {
(clone $this->query)->updateOrCreate([
$recordKeyName => $record[$recordKeyName] ?? null,
], $record);
});
}
Here's an implementation with upsert
as an alternative to updateOrCreate
. In upsertRecords
we map over the records and add the foreign key. We also make sure that the id
is always present, even for new records (null
). This is needed for upsert
to work correctly.
protected function upsertRecords()
{
$values = $this->records->map(function ($record) {
// Set $record['recipe_id'] to parent key.
$record[
$this->query->getForeignKeyName()
] = $this->query->getParentKey();
// Set $record['id'] to null when missing.
$recordKeyName = $this->query->getRelated()->getKeyName();
$record[$recordKeyName] = array_key_exists($recordKeyName, $record)
? $record[$recordKeyName]
: null;
return $record;
})->toArray();
(clone $this->query)->upsert(
$values,
[$this->query->getRelated()->getKeyName()],
);
}
Some people might prefer upsert
over updateOrCreate
, others might be using UUIDs. Nevertheless, you now have all the knowledge you need to build this macro, or a variation that fits your situation best.