skip to Main Content

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 AppTraits;

use IlluminateDatabaseEloquentBuilder;

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.

2

Answers


  1. Chosen as BEST ANSWER

    The solution I found was to override the fill() method found in IlluminateDatabaseEloquentModel 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 IlluminateDatabaseEloquentModel over time, but for now this works well. Please comment any improvements in readability or elegance. The code is below.

    <?php
    
    namespace AppTraits;
    
    use IlluminateDatabaseEloquentMassAssignmentException;
    
    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 IlluminateDatabaseEloquentModel, with the
         * addition of the first call to doPromotion() that
         * this trait enables
         *
         * @param  array  $attributes
         * @return $this
         *
         * @throws IlluminateDatabaseEloquentMassAssignmentException
         */
        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;
        }
    }
    

  2. I think using a global observer is better for this

    <?php
    
    namespace AppTraits;
    
    use IlluminateDatabaseEloquentBuilder;
    
    trait PromotesInputsDuringCreation {
    
        public static function doPromotion($attrs) {...};
        
        public static function bootPromotesInputsDuringCreation()         
        {          
             static::observe(app(PromotesInputsObserver::class));    
        }
    }
    

    than you can create this PromotesInputsObserver and implement your creating and updating events. But since getDirty is looking for dirty attributes and your loan_status is not a property of your model, it will not show up as a dirty attribute. Your loan_status is never being compared.

    class PromotesInputsObserver
    {
        public function creating(Model $model)
        {
            $model::doPromotion($model->only($model::$promotions));
            
            foreach($model::$promotions => $target) {
                unset($model->$target);
            }
        }
        ...
    }
    

    also rather that defining promotions as a static property of the model and than checking if that property exists, it’s easier to define it as a method public function promotions(): array and implement it on your trait as return [];. Than each model can overwrite this method with it’s own array but you don’t need to worry if that function exists on the model or not.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search