JanBoehmer
JanBoehmer

Reputation: 555

How to cascade soft deletes in Laravel?

How can I (eloquently) cascade a soft delete in Laravel? So when there is a post with comments for example, where both tables have soft deletes. When I remove the post, I want to remove the comments at well.

I would expect something like:

class Post extends Model
{
    use SoftDeletes;

    protected $cascadeSoftDeletes = ['comments'];

    ...
}

Upvotes: 6

Views: 6757

Answers (4)

SenneVP
SenneVP

Reputation: 115

Most packages that provide cascading deletes require you to setup some variable on the model class that basically holds all the relations to cascade, then add at trait that deals with them, like so:

class Post extends Model
{
    use SoftDeletes, CascadeSoftDeletes;

    protected $cascadeDeletes = ['comments'];
    ...

This works fine, however in my case I already had many many models in my project and did not want to go back and x-check which relations I should cascade or not. And I thought this info should already be available through the migrations onDelete('cascade') I had setup. Here is my solution.

Using the package doctrine/dbal (I installed it with composer require doctrine/dbal:3.8.3, it had to be 3.8.3 for me, later verions were not copatible with my setup) we have access to the foreign constrains on the DB tables. This gives us access to the onDelete functionality that has been setup. First we need to find DB tables that are linked to a model. Then we can scan those tables foreign key constrains to find cascading deletes. Therefor I wrote 3 functions in a Tools class as a Service:

<?php

namespace App\Services;

use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Eloquent\Model;

class Tools
{
    /**
     * Get all model classes.
     * Loop through all app files,
     * see if the file encodes a class,
     * check if the class is sublcass of 
     * Illuminate\Database\Eloquent\Model.
     * 
     * @return string[] $models
     */
    public static function getAllModels()
    {
        $models = [];

        // Loop all files in the app folder
        foreach (File::allFiles(app_path()) as $path) {
            // Skip non php files
            if (!str_ends_with($path, '.php')) {
                continue;
            }

            // Create a correct class string from the file path
            $class = str_replace(app_path(), '', $path);
            $class = str_replace('.php', '', $class);
            $class = '\\App' . str_replace('/', '\\', $class);

            
            // See if the class name indeed holds a class.
            if (!class_exists($class)) {
                continue;
            }

            // See if the class is subclass of
            // Illuminate\Database\Eloquent\Model
            $ref = new \ReflectionClass($class);
            if (!$ref->isSubclassOf(Model::class)){
                continue;
            }
            
            // See if the class is abstract.
            if ($ref->isAbstract()) {
                continue;
            }

            // Append the class string to the array
            array_push($models, $class);
        }
        return $models;
    }

    /**
     * Get a map pointing from each table to its model.
     * 
     * @return string[] $tableToModelMap
     */
    public static function getAllTablesWithModel() 
    {
        // Grab all model classes
        $models = static::getAllModels();

        // Fill the map with table names
        $tableToModelMap = [];
        foreach ($models as $model) {
            // Create a new model object.
            // getTable is not static so an instance is needed.
            $obj = new $model();

            // Fill the map
            $tableToModelMap[$obj->getTable()] = $model;
        }

        return $tableToModelMap;
    }

    /**
     * Get all models, with the foreign columns,
     * that cascade the delete of the given model.
     * 
     * @param \Illuminate\Database\Eloquent\Model $model
     * @return string[][] $modelToColumnsMap
     */
    public static function getCascadeDeleteModels($model)
    {
        // Get all the existing tables in the Doctrine DBAL format
        $all_tables = Schema::getConnection()
        ->getDoctrineSchemaManager()->listTables();

        // Get all tables linked to a model
        $tableToModelMap = static::getAllTablesWithModel();

        $modelToColumnsMap = [];
        // Loop all possible tables
        foreach ($all_tables as $table) {
            // Skip tables that are not linked to a model
            if (!key_exists($table->getName(), $tableToModelMap)) {
                continue;
            }

            // Grab all Doctrine\DBAL\Schema\ForeignKeyConstraint
            // of the current table
            $fks = $table->getForeignKeys();

            // Loop the FK constraints
            foreach ($fks as $fk) {
                // Check if the FK is linked to the table of the given model object
                if ($fk->getForeignTableName() == $model->getTable()) {
                    // See if the FK is cascade on delete
                    if ($fk->onDelete() == 'CASCADE') {
                        // Grab the model linked to the current table
                        $fk_model = $tableToModelMap[$table->getName()];

                        // Grab the column linked to the FK
                        $fk_column = $fk->getLocalColumns()[0];
                        
                        // Fill the map
                        if (key_exists($fk_model, $modelToColumnsMap)) {
                            $modelToColumnsMap[$fk_model][] = $fk_column;
                        } else {
                            $modelToColumnsMap[$fk_model] = [$fk_column];
                        }
                    }
                }
            }
        }
        
        return $modelToColumnsMap;
    }
}

The getAllModels function returns an array of all the model classes defined in the app folder. getAllTablesWithModel loops those models to grab their table name and make a map 'table_name' => '\App\My\Path\ModelString'. Finally getCascadeDeleteModels($model) loops those tables and sees if any have a foreign key linked to the given $model class, and sees if they are set up to delete on cascade.

Next thing to do is to make a trait for the models to cascade the delete:

<?php

namespace App\Models;

use App\Services\Tools;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

trait SoftDeleteTools
{
    /**
     * Bootstrap function that will be called in the model using this trait.
     * It will be called in during the 'boot()' method of said model.
     * 
     */
    public static function bootSoftDeleteTools()
    {
        // Hook the deleting life cycle function
        static::deleting(function ($model) {
            if (static::hasSoftDeletes($model::class)) {
                if (!$model->forceDeleting) {
                    $model->cascadeSoftDelete();
                }
            }
        });
    }

    /**
     * Cascade soft delete.
     */
    public function cascadeSoftDelete()
    {
        $classes = Tools::getCascadeDeleteModels($this);
        foreach ($classes as $class => $columns) {
            if (static::hasSoftDeletes($class)) {
                // whereAny is from laravel 10.47 only
                // $objects = $class::whereAny($columns, '=', $this->id)->get();
                if (count($columns) == 0) {
                    return;
                } else {
                    $query = $class::where($columns[0], '=', $this->id);
                    foreach ($columns as $idx => $column) {
                        if ($idx == 0) {
                            continue;
                        }
                        $query->orWhere($column, '=', $this->id);
                    }
                }
                $objects = $query->get();

                // Destroy the objects.
                // We don't use delete on the query, 
                // otherwise model deleting event is missed
                // and nested cascades are missed.
                $class::destroy($objects);
            }
        }
    }

    /**
     * See if given class has SoftDeletes trait.
     * 
     * @param string $class
     * @return bool
     */
    public static function hasSoftDeletes($class)
    {
        return in_array(SoftDeletes::class, class_uses_recursive($class));
    }
}

Then all that is left to do is to hook up the trait to some models. I did it with a CoreModel class to hook up all my models:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class CoreModel extends Model
{
    use SoftDeletes;
    use SoftDeleteTools;
}

There you have it. Probably not the cleanest code, models defined outside of the app directory are missed. But good enough for my purposes. I hope this solution might still help someone!

Upvotes: 1

AJ Zack
AJ Zack

Reputation: 383

this worked for me, in your Model add:

  public static function boot()
  {
    parent::boot();
    static::deleting(function ($post) {
      $post->comments()->delete();
    });
  }

Upvotes: 2

Sherif
Sherif

Reputation: 1537

you can do Apollo's solution or you could use a package that handle's this like

https://github.com/michaeldyrynda/laravel-cascade-soft-deletes

Upvotes: 4

Kevin
Kevin

Reputation: 1185

To soft delete relations, you have to do it using model observers

https://laracasts.com/discuss/channels/laravel/laravel-soft-delete-cascade Here is an example well explained.

Upvotes: 5

Related Questions