Search code examples
phplaraveleloquenttraits

Laravel: How to access raw input during create and update, from a trait?


I am writing a Trait for Laravel eloquent models that enables "promotion" of input elements during create and update. The base trait looks like this:

<?php

namespace App\Traits;

use Illuminate\Database\Eloquent\Builder;

trait PromotesInputsDuringCreation {

    public static function doPromotion($attrs) {
        if (isset(static::$promotions) && !empty(static::$promotions)){
            foreach (static::$promotions as $target => $path) {
                $pathParts = explode('.', $path);
                $targetData = $attrs;
                foreach ($pathParts as $part){
                    if (is_null($targetData)) {
                        break;
                    }
                    $targetData = is_object($targetData) ? $targetData->$part : $targetData[$part];
                }
                if(!is_null($targetData)) {
                    $attrs[$target] = $targetData;
                }
            }
        }
        return $attrs;
    }

The models that implement the trait would define a $promotions array that looks something like this:

public static $promotions = [
    'loan_status_end_date' => 'loan_status.end_date',
    'loan_status_type' => 'loan_status.type'
];

The goal of this trait is to accept inputs with nested arrays/objects full of data, and to selectively promote some of that data to new top-level keys. This enables easier sorting/searching at the database level once it is saved.

The issue I am having is that no solution I have tried "does it all"

First solution: The boot method

Problem: Boot method callbacks receive an already instantiated model which does not contain the raw data passed into ::create() / ::updateOrCreate() / similar methods. (Or, if it does, I am not aware how to access it. Please let me know if I am dumb)

Example Code:

    public static function bootPromotesInputsDuringCreation()
    {
        static::creating(function ($model) {
//$model->getDirty() only gets attributes that exactly match data keys
            $model->attributes = array_intersect_key(self::doPromotion($model->getDirty()), $model->attributes);
        });
        static::updating(function ($model) {
//$model->getDirty() only gets attributes that will change in the update
            $model->attributes = array_intersect_key(self::doPromotion($model->getDirty()), $model->attributes);
        });
    }

Second solution: Overriding creation methods

Problem: The promotion works, but the following creation method skips model casting and other laravel niceties I rely on. All attempts to use static::create() or parent::create() with the promoted data create infinite loops.

Example Code:

    public static function create($data) 
    {
        return (new static)->newQuery()->create(self::doPromotion($data));
    }

    public static function updateOrCreate($where, $data)
    {
        $data = self::doPromotion($data);

        if ((new static)->newQuery()->where($where)->exists()) {
            // this skips $casts and other model events :(
            return (new static)->newQuery()->where($where)->update($data);
        }

        return (new static)->newQuery()->create([...$where, ...$data]);
    }

It really feels like I am inches away from making this trait work. Anyone see what I am missing? Could be as simple as finding a way to get the raw creation/update input inside the boot method or calling create/update in the overridden methods in a different way that invokes those laravel niceties.

Thanks in advance.


Solution

  • The solution I found was to override the fill() method found in Illuminate\Database\Eloquent\Model with an exact copy on my trait that simply adds the call to doPromotions() before continuing its usual processing.

    Id like this to be cleaner and more resilient to changes in Illuminate\Database\Eloquent\Model over time, but for now this works well. Please comment any improvements in readability or elegance. The code is below.

    <?php
    
    namespace App\Traits;
    
    use Illuminate\Database\Eloquent\MassAssignmentException;
    
    trait PromotesInputsDuringCreation {
    
        public static function doPromotion($attrs) {
            if (isset(static::$promotions) && !empty(static::$promotions)){
                foreach (static::$promotions as $target => $path) {
                    $pathParts = explode('.', $path);
                    $targetData = $attrs;
                    $success = true; 
                    //re-checking arbitrary states at the end can be ambiguous, $success makes it explicit.
                    foreach ($pathParts as $part){
                        if (is_null($targetData)) {
                            $success = false;
                            break;
                        }
                        if ((is_object($targetData) && !isset($targetData->$part)) 
                            || (!is_object($targetData) && !isset($targetData[$part]))){
                                $success = false;
                                break;
                            }
                        $targetData = is_object($targetData) ? $targetData->$part : $targetData[$part];
                    }
                    if($success) {
                        $attrs[$target] = $targetData;
                    }
                }
            }
            return $attrs;
        }
    
        /**
         * Fill the model with an array of attributes.
         * 
         * This is an identical copy of the same method 
         * from Illuminate\Database\Eloquent\Model, with the
         * addition of the first call to doPromotion() that
         * this trait enables
         *
         * @param  array  $attributes
         * @return $this
         *
         * @throws \Illuminate\Database\Eloquent\MassAssignmentException
         */
        public function fill(array $attributes)
        {
            $attributes = self::doPromotion($attributes);
    
            $totallyGuarded = $this->totallyGuarded();
    
            $fillable = $this->fillableFromArray($attributes);
    
            foreach ($fillable as $key => $value) {
                // The developers may choose to place some attributes in the "fillable" array
                // which means only those attributes may be set through mass assignment to
                // the model, and all others will just get ignored for security reasons.
                if ($this->isFillable($key)) {
                    $this->setAttribute($key, $value);
                } elseif ($totallyGuarded || static::preventsSilentlyDiscardingAttributes()) {
                    if (isset(static::$discardedAttributeViolationCallback)) {
                        call_user_func(static::$discardedAttributeViolationCallback, $this, [$key]);
                    } else {
                        throw new MassAssignmentException(sprintf(
                            'Add [%s] to fillable property to allow mass assignment on [%s].',
                            $key, get_class($this)
                        ));
                    }
                }
            }
    
            if (count($attributes) !== count($fillable) &&
                static::preventsSilentlyDiscardingAttributes()) {
                $keys = array_diff(array_keys($attributes), array_keys($fillable));
    
                if (isset(static::$discardedAttributeViolationCallback)) {
                    call_user_func(static::$discardedAttributeViolationCallback, $this, $keys);
                } else {
                    throw new MassAssignmentException(sprintf(
                        'Add fillable property [%s] to allow mass assignment on [%s].',
                        implode(', ', $keys),
                        get_class($this)
                    ));
                }
            }
    
            return $this;
        }
    }