Back to Course |
Structuring Databases in Laravel 11

HasMany Delete Parents: Validate or Cascade

In a hasMany relationship, what should happen with the children's record if you delete the parent record? I will show you three ways how to deal with that situation.

Here are our options:

  1. Validation on the Laravel level: How can that error be processed more gracefully?
  2. Validation on DB level: in Laravel migrations.
  3. Using soft-deletes instead of deleting anything.

Laravel Project

First, the project setup and the problem itself.

In the project, we have two Models: Company and Contact. A contact belongs to a company.

database/migrations/xxx_create_companies_table.php:

Schema::create('companies', function (Blueprint $table) {
$table->id();
$table->string('company_name');
$table->string('company_address');
$table->string('company_website');
$table->string('company_email');
$table->timestamps();
});

database/migrations/xxx_create_contacts_table.php:

Schema::create('contacts', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained();
$table->string('contact_name');
$table->string('contact_last_name');
$table->string('contact_phone_1');
$table->string('contact_phone_2');
$table->string('contact_email');
$table->string('contact_skype');
$table->string('contact_address');
$table->timestamps();
});

To delete a company in the Controller, simply call the delete() method on the Company Model, which comes from the Route Model Binding.

use App\Models\Company;
use Illuminate\Http\RedirectResponse;
 
class ContactCompanyController extends Controller
{
// ...
 
public function destroy(Company $contactCompany): RedirectResponse
{
$contactCompany->delete();
 
return redirect()->back();
}
}

When the delete method is executed without any additional checks, the Integrity constraint violation error is shown.

Now, what are our options to solve it?


Option 1: Laravel Validation on Delete

Let's validate if the company has children of contacts. If it does, do not allow deleting them. There are a few ways to do that, but the most straightforward is adding an if statement in the destroy() method.

use App\Models\Company;
use Illuminate\Http\RedirectResponse;
use Illuminate\Validation\ValidationException;
 
class ContactCompanyController extends Controller
{
public function destroy(Company $company): RedirectResponse
{
if ($company->contacts()->exists()) {
throw ValidationException::withMessages([
'contacts_exists' => 'Cannot delete, company has contact records.',
]);
}
 
$company->delete();
 
return redirect()->back();
}
}

Now, if you try to delete, you should see the validation message.


Option 2: Show/Hide Buttons with Data Upfront

What if we know upfront that some company has contacts? Maybe then we just shouldn't show the delete button for that company?

In the Controller, we eagerly load the contacts.

use App\Models\Company;
use Illuminate\Contracts\View\View;
 
class ContactCompanyController extends Controller
{
public function index(): View
{
$companies = Company::with('contacts')->get();
 
return view('companies.index', compact('companies'));
}
 
// ...
}

And then, in the View, we do an if check to show a delete button.

// ...
@if(empty($company->contacts()->count()))
<form action="{{ route('companies.destroy', $company) }}" method="POST">
@csrf
@method('DELETE')
 
<x-danger-button>
Delete
</x-danger-button>
</form>
@endif
// ...

Now, the delete button is only shown for records that don't have contacts.

But I wonder if this method is suitable.

First, you still need to validate that on the back-end because you cannot trust front-end validation at any time.

It also adds performance issues: in the Controller, you must load more relationships for all the records. But what if there are more records inside that company? Transactions, employees, documents, whatever, you would have to check all of them while loading the table.

And 99% of the users won't ever need to delete anything. So you are loading a lot of data for 1% of the customers. So, instead of the front-end validation, only doing the back-end validation is enough.


Option 3: Validation on Migration

You can specify the onDelete() action when creating foreign keys. By default in MySQL, the value is NO ACTION, which acts like RESTRICT, meaning it would show an SQL error when trying to delete the parent record.

Other possible values are CASCADE and SET NULL. The constrained migration looks like this:

database/migrations/xxx_create_contacts_table.php:

Schema::create('contacts', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained();
$table->string('contact_name');
$table->string('contact_last_name');
$table->string('contact_phone_1');
$table->string('contact_phone_2');
$table->string('contact_email');
$table->string('contact_skype');
$table->string('contact_address');
$table->timestamps();
});

Let's try both CASCADE and SET NULL in action.

database/migrations/xxx_create_contacts_table.php:

Schema::create('contacts', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained()->onDelete('cascade');
$table->string('contact_name');
$table->string('contact_last_name');
$table->string('contact_phone_1');
$table->string('contact_phone_2');
$table->string('contact_email');
$table->string('contact_skype');
$table->string('contact_address');
$table->timestamps();
});

With this rule, after deleting the company, all the contacts associated with it are also automatically deleted. There is no need to add any code for this in Laravel Controller.

Another option is SET NULL. When setting a foreign key to null, it must first be set to nullable.

database/migrations/xxx_create_contacts_table.php:

Schema::create('contacts', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->nullable()->constrained()->onDelete('SET NULL');
$table->string('contact_name');
$table->string('contact_last_name');
$table->string('contact_phone_1');
$table->string('contact_phone_2');
$table->string('contact_email');
$table->string('contact_skype');
$table->string('contact_address');
$table->timestamps();
});

After deleting the company, all the records in the contacts table that were associated with it were set to NULL.


Option 3: Soft Deletes

Another popular way of dealing with all of that is to use SoftDeletes, which are easy to add in Laravel.

database/migrations/xxx_create_companies_table.php:

Schema::create('companies', function (Blueprint $table) {
$table->id();
$table->string('company_name');
$table->string('company_address');
$table->string('company_website');
$table->string('company_email');
$table->timestamps();
$table->softDeletes();
});

database/migrations/xxx_create_contacts_table.php:

Schema::create('contacts', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->nullable()->constrained()->onDelete('SET NULL');
$table->string('contact_name');
$table->string('contact_last_name');
$table->string('contact_phone_1');
$table->string('contact_phone_2');
$table->string('contact_email');
$table->string('contact_skype');
$table->string('contact_address');
$table->timestamps();
$table->softDeletes();
});

app/Models/Company.php:

use Illuminate\Database\Eloquent\SoftDeletes;
 
class Company extends Model
{
use HasFactory;
use SoftDeletes;
 
// ...
}

app/Models/Contact.php:

use Illuminate\Database\Eloquent\SoftDeletes;
 
class Contact extends Model
{
use HasFactory;
use SoftDeletes;
 
// ...
}

When we delete a company now, it gets deleted, but no changes are made in the contacts table. We didn't tell DB to delete the companies record from the database. Laravel only marks it as deleted, and child records are not triggered by it.

If you want to cascade the child records with the soft deletes, you can do that with a package like michaeldyrynda/laravel-cascade-soft-deletes.