DEV Community

marius-ciclistu
marius-ciclistu

Posted on • Originally published at marius-ciclistu.Medium on

Make Eloquent Faster And Safer With Laravel CRUD Wizard Free


Laravel CRUD Wizard Free

I wrote multiple articles about it but this blog post aims on summarizing the features that might convince you to use it even if you have already a simple CRUD in place.

Current version (5.1.0 at the time I’m writing this article) has RETROACTIVE extra features , improvements and fixes for uncovered issues and corner cases.

I will extract them here from the above mentioned blog post which analyzes the BaseModel:

abstract class BaseModel extends Model
{
    use HasCleverRelationships;
Enter fullscreen mode Exit fullscreen mode

Right from the start you can see this HasCleverRelationships trait that is being used to avoid/fix an eager load unsolved issue that Laravel considers a corner case but many others, including us, see it as a data leak vulnerability.

    public const RESOURCE_NAME = null;
Enter fullscreen mode Exit fullscreen mode

The resource name used for your CRUD routes can be guessed by the below resourceName method if not declared in your model.

    public const WITH_RELATIONS = [];
Enter fullscreen mode Exit fullscreen mode

This can contain the list of relations that will be exposed via API for this resource.

    public const CREATED_AT_FORMAT = 'Y-m-d H:i:s';
    public const UPDATED_AT_FORMAT = 'Y-m-d H:i:s';
Enter fullscreen mode Exit fullscreen mode

This will be the format in which the created_at and updated_at columns are auto populated for DB storage by the lib.

    public const COMPOSITE_PK_SEPARATOR = '_';
Enter fullscreen mode Exit fullscreen mode

The lib supports composite primary keys as opposed to Eloquent. Change this separator if you need/want.

    /**
     * Setting this to true will not append the primary_key_identifier on response
     * Leave it false if you use casts or any logic that alters the attributes of the model
     */
    public const LIST_UN_HYDRATED_WHEN_POSSIBLE = false;
Enter fullscreen mode Exit fullscreen mode

Eloquent will be used to translate the request into SQL and then the retrieval of the results is done as stdClass , skipping the hydration and improving speed, but this will happen only when the result is the same as the result of using Eloquent (except for the appended primary_key_identifier which can be added later by using laravel-crud-wizard-decorator-free lib).

    public $timestamps = false;
Enter fullscreen mode Exit fullscreen mode

As previously mentioned, the lib has its own logic for timestamps as datetime. You can fallback to Eloquent logic by setting this to true and overriding the boot method.

    public static $snakeAttributes = false;
Enter fullscreen mode Exit fullscreen mode

Our libs are following PSR12 coding style that means the relations are in camelCase format. To maintain that also on the responses and in the request, this needs to be false.

    public BaseModelAttributesInterface $a;
    public BaseModelAttributesInterface $r;
Enter fullscreen mode Exit fullscreen mode

These 2 new properties allow segregating the model's PHP properties from its DB columns and relations to avoid clashes, allowing autocomplete, property hooks and casting via property hooks if you are using PHP >= 8.4.

    protected bool $returnNullOnInvalidColumnAttributeAccess = true;
Enter fullscreen mode Exit fullscreen mode

Set this to false if you want. It will mean that if you access a column or relation that does not exist and is not set in the model, you will get a PHP exception instead of null.

    protected array $ignoreUpdateFor = [];
Enter fullscreen mode Exit fullscreen mode

List of columns that should not be updated (see also allowNonExternalUpdatesFor ).

    protected array $ignoreExternalCreateFor = [];
Enter fullscreen mode Exit fullscreen mode

List of columns that should not be used for creation via API (used in controller).

    protected array $allowNonExternalUpdatesFor = [];
Enter fullscreen mode Exit fullscreen mode

List of columns from ignoreUpdateFor that can be updated internally, so not via controller.

    protected bool $indexRequiredOnFiltering = true;
Enter fullscreen mode Exit fullscreen mode

If index is required on filtering, then at least one column from the filters must have an index in DB. This is to prevent queries that might timeout due to not using an index.

    protected $hidden = [
        'laravel_through_key'
    ];
Enter fullscreen mode Exit fullscreen mode

laravel_through_key is used for eager loading so no need for it to be exposed via API.

    /**
     * Temporary cache to avoid multiple getDirty calls generating multiple set calls for
     * sync/merge casted attributes to objects to persist the possible changes made to those objects
     */
    protected ?array $tmpDirtyIfAttributesAreSyncedFromCashedCasts = null;
Enter fullscreen mode Exit fullscreen mode

This property introduced in version 5.1.0 helps to improve eloquent speed when using casts on update operations, reducing the number of set calls discussed here that was not fixed in Laravel. This is used also for locking updates on the model when set to [].

    /**
     * Temporary original cache to prevent changes in created,updated,saved events from getting
     * into $original without being saved into DB
     */
    protected ?array $tmpOriginalBeforeAfterEvents = null;
Enter fullscreen mode Exit fullscreen mode

This property introduced in version 5.1.0 fixes an unsolved issue from Laravel.

private array $incrementsToRefresh = [];
Enter fullscreen mode Exit fullscreen mode

Eloquent increments the value from the model and then for DB storage it does not use that value when incrementing. That means that the value after increment can differ from the one stored in DB due to asynchronous queries. The lib refreshes the value (all from this list) from DB on its first access after the increment is done.

    /**
     * @inheritdoc
     */
    public function __construct(array $attributes = [])
    {
        if ($this->incrementing) {
            $this->casts[$this->getKeyName()] ??= $this->getKeyType();
        }
Enter fullscreen mode Exit fullscreen mode

This is to improve casts as stated in this unsolved issue from Laravel. Setting the primary key on construct is better that setting it on getCasts method because getCasts is called multiple times as described in the issue.

    public function newEloquentBuilder($query): CleverEloquentBuilder
    {
        return new CleverEloquentBuilder($query);
    }
Enter fullscreen mode Exit fullscreen mode

The custom Eloquent builder is needed for covering the eager load issue mentioned above.

    public static function boot(): void
    {
        parent::boot();
        static::creating(function (BaseModel $baseModel): void {
            $baseModel->setCreatedAt(Carbon::now()->format($baseModel::CREATED_AT_FORMAT));
        });

        static::updating(function (BaseModel $baseModel): void {
            $updatedAtColumn = $baseModel->getUpdatedAtColumn();

            if ('' === $baseModel->getAttribute($updatedAtColumn)) {
Enter fullscreen mode Exit fullscreen mode

When setting updated_at to empty string, it being datetime or null , means that you will update the row without changing its value.

    /**
     * @throws \Exception
     */
    public function getFrozen(): BaseModelFrozenAttributes
    {
        $frozenAttributes =
            \substr($class = static::class, 0, $l = (-1 * (\strlen($class) - \strrpos($class, '\\') - 1))) .
            'Attributes\\' . \substr($class, $l) . 'FrozenAttributes';

        if (\class_exists($frozenAttributes)) {
            return new $frozenAttributes((clone $this)->forceFill($this->toArray()));
        }

        throw new \Exception('Class not found: ' . $frozenAttributes);
    }
Enter fullscreen mode Exit fullscreen mode

This method can be used to get the model as stdClass like read only DTO.

    /**
     * @inheritDoc
     * @throws \Exception
     * @see static::returnNullOnInvalidColumnAttributeAccess
     */
    public function getAttributeValue($key): mixed
    {
        if ($this->exists && isset($this->incrementsToRefresh[$key])) {
            $this->attributes = \array_merge(
                $this->attributes,
                (array)($this->setKeysForSelectQuery($this->newQueryWithoutScopes())
                    ->useWritePdo()
                    ->select($attributes = \array_keys($this->incrementsToRefresh))
                    ->first()
                    ?->toArray())
            );
            $this->syncOriginalAttributes($attributes);
            $this->incrementsToRefresh = [];
        }

        $return = $this->transformModelValue($key, $this->getAttributeFromArray($key, true));
Enter fullscreen mode Exit fullscreen mode

Not calling the parent here is part of the improvement in casting from this discussion.

    public function attributeOffsetUnset(string $offset): void
    {
        unset($this->attributes[$offset]);
    }

    public function attributeOffsetExists(string $offset): bool
    {
        return isset($this->attributes[$offset]);
    }
Enter fullscreen mode Exit fullscreen mode

The above offset methods are needed for segregating the active record properties from its DB columns.

    /**
     * @inheritdoc
     */
    public function getCasts()
    {
        return $this->casts;
    }
Enter fullscreen mode Exit fullscreen mode

As mentioned above, the override is needed for speeding up Eloquent. See the __construct method.

    /**
     * This will mass update the whole table if the model does not exist!
     * @inheritDoc
     * @throws \InvalidArgumentException
     */
    protected function incrementOrDecrement($column, $amount, $extra, $method): int
    {
        if (!$this->exists) {
            return $this->newQueryWithoutRelationships()->{$method}($column, $amount, $extra);
        }

        $this->{$column} = $this->isClassDeviable($column)
            ? $this->deviateClassCastableAttribute($method, $column, $amount)
            : (\extension_loaded('bcmath') ? \bcadd(
                $s1 = (string)$this->{$column},
                $s2 = (string)($method === 'increment' ? $amount : $amount * -1),
                \max(\strlen(\strrchr($s1, '.') ?: ''), \strlen(\strrchr($s2, '.') ?: ''))
            ) : $this->{$column} + ($method === 'increment' ? $amount : $amount * -1));

        $this->forceFill($extra);

        if (!$this->isDirty() || $this->fireModelEvent('updating') === false) {
            return 0;
        }
Enter fullscreen mode Exit fullscreen mode

Calling isDirty here will merge the casted attributes into objects back from those objects and also will help in preventing the model update when it is locked. More details can be found in this discussion.

    /**
     * @see static::a
     * @see static::p
     */
    protected function initializeActiveRecordSegregationProperties(): void
    {
        $this->a = new BaseModelLazyAttributes($this);
        $this->r = new BaseModelLazyRelations($this);
    }
Enter fullscreen mode Exit fullscreen mode

To improve speed , by default the target attribute/relation classes will not be dynamically sought and instantiated until their first usage. If you want (or if you want to place them in a different place in your folder structure than in an Attributes folder next to your model location) you can override this method and instantiate them directly.

    /**
     * @inheritdoc
     * @throws \BadMethodCallException
     */
    public function __call($method, $parameters)
    {
        $lowerMethod = \strtolower($method);

        if ($lowerMethod === 'getattributefromarray') {
Enter fullscreen mode Exit fullscreen mode

This is needed for improving casts. More details can be found in this discussion.

            return $this->$method(...$parameters);
        }

        if (\in_array($lowerMethod, ['incrementeach', 'decrementeach'], true)) {
            /** \MacropaySolutions\MaravelRestWizard\Models\BaseModel::incrementBulk can be used instead */
            throw new \BadMethodCallException(\sprintf(
                'Call to unscoped method %s::%s(). Use $model->newQuery()->getQuery()->%s()' .
                ' for unscoped or $model->newQuery()->%s() for scoped behavior.',
                static::class,
                $method,
                $method,
                $method,
            ));
Enter fullscreen mode Exit fullscreen mode

This is to prevent yet again an uncovered corner case from Laravel reported in these: issue 57262, issue 49009, issue 48595.


        }

        return parent::__call($method, $parameters);
    }

    /**
     * @inheritdoc
     */
    public function newCollection(array $models = []): \Illuminate\Database\Eloquent\Collection
    {
        return \function_exists('arrayUniqueSortRegular')
            && \Composer\InstalledVersions::isInstalled('macropay-solutions/maravel-framework') ?
                parent::newCollection($models) :
                new class ($models) extends \Illuminate\Database\Eloquent\Collection {
                    /**
                     * @inheritdoc
                     */
                    public function toBase(): Collection
                    {
                        return new class ($this) extends Collection {
                            /**
                             * Return only unique items from the collection array.
                             *
                             * @param mixed $key
                             * @param bool $strict
                             * @param int $flags [optional] <p>
                             * The optional second parameter sort_flags
                             * may be used to modify the sorting behavior using these values:
                             * </p>
                             * <p>
                             * Sorting type flags:
                             * </p><ul>
                             * <li>
                             * <b>SORT_REGULAR</b> - compare items normally
                             * (don't change types)
                             * </li>
                             * <li>
                             * <b>SORT_NUMERIC</b> - compare items numerically
                             * </li>
                             * <li>
                             * <b>SORT_STRING</b> - compare items as strings
                             * </li>
                             * <li>
                             * <b>SORT_LOCALE_STRING</b> - compare items as strings,
                             * based on the current locale
                             * </li>
                             * </ul>
                             * @return static
                             */
                            public function unique($key = null, $strict = false): Collection
                            {
                                if ($key === null && $strict === false) {
                                    $flags = \func_get_args()[2] ?? SORT_REGULAR;

                                    return new static(
                                        SORT_REGULAR === $flags ?
                                            GeneralHelper::arrayUniqueSortRegular($this->items) :
                                            \array_unique($this->items, $flags)
                                    );
                                }

                                return parent::unique($key, $strict);
                            }
                        };
                    }
                };
    }
Enter fullscreen mode Exit fullscreen mode

Again fixing unsolved issues from PHP: php/php-src/issues/20262#issuecomment-3441217772 and Laravel laravel/framework/issues/57528.

    /**
     * Prevent updates
     * Note that relations can be loaded and updated during the lock
     */
    public function lockUpdates(bool $checkDirty = true): bool
    {
        if (
            !$this->exists
            || $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts !== null
            || ($checkDirty && $this->isDirty())
        ) {
            return false;
        }

        $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = [];

        return true;
    }

    /**
     * Unlock updates
     *
     * To reset the model's $attributes and get the changes from dirty applied during the lock use:
     *
     * if ($this->unlockUpdates()) {
     * $dirty = $this->getDirty();
     * $this->attributes = $this->original;
     * $this->classCastCache = [];
     * $this->attributeCastCache = [];
     * }
     *
     * Note that relations can be loaded during the lock
     */
    public function unlockUpdates(): bool
    {
        if ($this->hasUnlockedUpdates()) {
            return false;
        }

        $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null;

        return true;
    }

    public function hasUnlockedUpdates(): bool
    {
        return $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts !== [];
    }
Enter fullscreen mode Exit fullscreen mode

The above 3 methods for preventing the model updates where introduced in version 5.1.0 and can be used when needed.

The rest of the code was added in the same version for improving casts as detailed in this discussion .

Top comments (0)