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 handle 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, 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. I'll be using upsert since this method can handle multiple records in one go (a single SQL query).
⚠️ If you want the updated_at
timestamps to be updated correctly, or you rely on Eloquent events, then you should use updateOrCreate
instead of upsert
. This will require you to loop over all incoming ingredients and pass them into the updateOrCreate
method.
Before looking at the macro, let's look at how an upsert works.
$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 (in this case we use the id
).
ℹ️ We could add a third argument to provide an array with the columns that should be updated when a matching record is found. In our case they can all be updated, 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 upsert, otherwise the newly added records will be deleted as well.
// Delete
$recipe
->ingredients()
->whereNotIn('id', [1, 2, 3])
->delete();
// 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 upsert 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->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 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 then 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 the upsert.
In upsertRecords
we map over the records and add the foreign key. We also make sure that 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 updateOrCreate
over upsert
, others might be using UUIDs. Nevertheless, you now have all the knowledge you need to use this macro, or a variation that fits your situation best.