Brain dead easy caching using Laravel's attribute mutators and dynamic properties.

Submitted by danrichards - 7 years ago

If you need to sprinkle in some caching without building a bunch of Repositories. I've found this base Model and Service Provider very helpful. For updates: https://gist.github.com/danrichards/de22efa41b533d81fa1ec15f42cf64f5

// Model.php

<?php

namespace App;

use Cache;
use Closure;
use Illuminate\Database\Eloquent\Model as BaseModel;

/**
 * Class Model
 */
abstract class Model extends BaseModel
{

    /**
     * If you want to specify the result of a Builder::get() as a property, you
     * may do so by specifying a query property. All this does is magically
     * call the a method or query scope on your call and hit it with get().
     *
     * @see \App\User::customers()
     *
     * e.g. User::find(1)->customers;   // \Illuminate\Database\Eloquent\Collection
     *
     * Works with objects you can call get() on.
     *
     * e.g. instanceof:
     *
     * \Illuminate\Database\Query\Builder
     *
     * To add caching automatically, use the $cache_related property instead.
     *
     * @var array $query_props */
    protected $query_props = [
        // 'method_name',       // Will not cache
        // 'ex_2' => ''         // Non-integer will not cache
        // 'ex_3' => 30,        // Cache for 30 minutes
        // 'ex_4' => 0          // Cache forever
    ];

    /**
     * Same as query props but more explicit in that there is caching.
     *
     * e.g. instanceof:
     *
     * \Illuminate\Database\Query\Builder
     * \Illuminate\Database\Query\HasMany
     * \Illuminate\Database\Query\HasManyOne
     * \Illuminate\Database\Query\HasManyThrough
     *
     * @var array
     */
    protected $cache_related = [
        // 'attr_name'          // Cache forever
        // 'ex_2' => 0          // Cache forever
        // 'ex_3' => 30         // Cache for 30 minutes
    ];

    /**
     * Use Laravel's get attribute mutator to conveniently store cached data.
     *
     * Leverage for simple solutions for Models that do not have Repositories
     * already, if you feel like you're doing something dirty, then that means
     * you should create a new Repository for your model.
     *
     * @see https://laravel.com/docs/5.1/eloquent-mutators#accessors-and-mutators
     * @var array $cache_attributes */
    protected $cache_attributes = [
        // 'attr_name'          // Cache forever
        // 'ex_2' => 0          // Cache forever
        // 'ex_3' => 30         // Cache for 30 minutes
    ];

    /**
     * Simple attribute caching, for more robust caching, use a Repository
     *
     * @param string $related
     * @param null $id
     * @return string
     */
    private function cacheAttributeKey($related, $id = null)
    {
        $id = $id ?: $this->getKey();
        return implode('|', [get_class($this), $id, 'attribute', $related]);
    }

    /**
     * Simple method caching, for more robust caching, use a Repository
     *
     * @param string $attribute
     * @param null $id
     * @return string
     */
    private function cacheRelatedKey($attribute, $id = null)
    {
        $id = $id ?: $this->getKey();
        return implode('|', [get_class($this), $id, 'related', $attribute]);
    }

    /**
     * @param string $attribute
     * @param int $minutes
     * @param Closure $callback
     * @return mixed
     */
    private function cacheAttribute($attribute, $minutes, Closure $callback)
    {
        return Cache::remember($this->cacheAttributeKey($attribute), $minutes, $callback);
    }

    /**
     * @param string $attribute
     * @param int $minutes
     * @param Closure $callback
     * @return mixed
     */
    private function cacheRelated($attribute, $minutes, Closure $callback)
    {
        return Cache::remember($this->cacheRelatedKey($attribute), $minutes, $callback);
    }

    /**
     * @param string|array|null $attribute
     */
    public function cacheAttributeBust($attribute = null, $parent_id)
    {
        $attribute = is_null($attribute)
            ? static::normalizeAssociative($this->cache_attributes, 0)
            : $attribute;

        $attribute = is_string($attribute)
            ? (array) $attribute
            : $attribute;

        foreach ($attribute as $a) {
            Cache::forget($this->cacheAttributeKey($a));
        }
    }

    /**
     * @param string|array|null $related
     */
    public function cacheRelatedBust($related = null)
    {
        $related = is_null($related)
            ? static::normalizeAssociative($this->cache_related, 0)
            : $related;

        $related = is_string($related)
            ? (array) $related
            : $related;

        foreach ($related as $r) {
            Cache::forget($this->cacheRelatedKey($r));
        }
    }

    /**
     * @param string $parent_class      The Model
     * @param string|int $parent_id     The Model's primary key value
     * @param string $sub_key           A classification / category
     * @param string $key               Cache key name
     */
    public static function cacheBust($parent_class, $parent_id, $sub_key, $key)
    {
        Cache::forget(implode('|', func_get_args()));
    }

    /**
     * @param string $name
     * @return mixed
     */
    public function __get($name)
    {
        $query_props_normalized = static::normalizeAssociative($this->query_props, 'no-cache');
        $cache_attributes_normalized = static::normalizeAssociative($this->cache_attributes, 0);
        $cache_related_normalized = static::normalizeAssociative($this->cache_related, 0);

        /**
         * Handle query props.
         */
        if (in_array($name, array_keys($query_props_normalized))) {
            return call_user_func([$this, $name])->get();
        }

        /**
         * Handle any related models that are cached.
         */
        if (in_array($name, array_keys($cache_related_normalized))) {
            $minutes = (int) $cache_related_normalized[$name];

            return $this->cacheRelated($name, $minutes, function() use($name) {
                return call_user_func([$this, $name])->get();
            });
        }

        /**
         * Handle and get attribute mutators that are cached.
         */
        if (in_array($name, array_keys($cache_attributes_normalized))) {
            $minutes = (int) $cache_attributes_normalized[$name];

            return $this->cacheAttribute($name, $minutes, function() use($name) {
                return $this->getAttribute($name);
            });
        }

        return parent::__get($name);
    }

    /**
     * Build out an associative array with mixed values.
     *
     * e.g.
     *
     * [
     *     'cats',
     *     'dogs' => 1
     * ]
     *
     * becomes
     *
     * [
     *    'cats' => 0,
     *    'dogs' => 1
     * ]
     *
     * @todo Move this method somewhere in a Util class.
     * 
     * @param array $arr
     * @param int $default_value
     * @return array
     */
    public static function normalizeAssociative(array $arr, $default_value = 0)
    {
        $normalized = [];

        foreach($arr as $key => $value) {
            $new_key = $key;
            $new_value = $value;
            if (is_int($key)) {
                $new_key = $value;
                $new_value = $default_value;
            }
            $normalized[$new_key] = $new_value;
        }

        return $normalized;
    }

}

// ModelCacheServiceProvider.php, don't forget to config/app.php

<?php

namespace App\Providers;

use App\Model;
// use App\User;
// use App\Models\Order;
// use App\Models\Listing;
use Illuminate\Support\ServiceProvider;

/**
 * Class ModelCacheServiceProvider
 */
class ModelCacheServiceProvider extends ServiceProvider
{

    /**
     * Automatically clear caches for related data, cached on Parent models.
     *
     * @see Model::$cache_related
     * @var array $uses_cache_related */
    public static $uses_cache_related = [
        // [
        //     'model' => User::class,
        //     'related' => Listing::class,
        //     'foreign_key' => 'user_id',
        //     'methods' => ['listings'],
        //     'events' => ['saved', 'created', 'deleted']
        // ]
    ];

    /**
     * Automatically clear caches for related data, cached on Parent models.
     *
     * @see Model::$cache_related
     * @var array $uses_cache_attributes */
    public static $uses_cache_attributes = [
        // [
        //     'model' => User::class,
        //     'related' => Commission::class,
        //     'foreign_key' => 'user_id',
        //     'attributes' => ['total_owed'],
        //     'events' => ['saved', 'created', 'deleted']
        // ]
    ];

    /**
     * Bootstrap the application services.
     *
     * @return void
     */
    public function boot()
    {

        // For any usages of "uses cache related"
        foreach (static::$uses_cache_related as $purge_event) {

            // And for any of the specified model events
            $events = $purge_event['events'];
            foreach($events as $event) {
                $model = $purge_event['model'];
                $related = $purge_event['related'];
                $foreign_key = $purge_event['foreign_key'];
                $methods = $purge_event['methods'];

                // Add a listener to the related model to purge the parent's key
                call_user_func([$related, $event], function($r) use($model, $foreign_key, $methods) {
                    $parent_id = $r->{$foreign_key};

                    // To bust the related caches
                    foreach ($methods as $method) {
                        call_user_func_array([$model, 'cacheBust'], [$model, $parent_id, 'related', $method]);
                    }
                });
            }
        }

        // For any usages of "uses cache attributes"
        foreach (static::$uses_cache_attributes as $purge_event) {

            // And for any of the specified model events
            $events = $purge_event['events'];
            foreach($events as $event) {
                $model = $purge_event['model'];
                $related = $purge_event['related'];
                $foreign_key = $purge_event['foreign_key'];
                $attributes = $purge_event['attributes'];

                // Add a listener to the related model to purge the parent's key
                call_user_func([$related, $event], function($r) use($model, $foreign_key, $attributes) {
                    $parent_id = $r->{$foreign_key};

                    // To bust the related caches
                    foreach ($attributes as $attribute) {
                        call_user_func_array([$model, 'cacheBust'], [$model, $parent_id, 'attribute', $attribute]);
                    }
                });
            }
        }

    }

    /**
     * Register the application services.
     *
     * @return void
     */
    public function register()
    {

    }

}