Skip to content

Read only model with lazy objects

macropay-solutions edited this page Aug 8, 2025 · 1 revision

Sometimes you need to pass a model to a function call and you don’t want any changes to be made to that model so, it should be treated as a read-only model.

You can accomplish that by using BaseModelFrozenAttributes class which is a DTO without setters.

Note that Reflection or Closure binding usage will retrieve/set protected stdClass, not Model — but the model can be retrieved from DB by its primary key that is readable in this frozen model.

Given this model:

<?php

namespace App\Models;

use App\Models\Attributes\OperationAttributes;
use App\Models\Attributes\OperationFrozenAttributes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use MacropaySolutions\LaravelCrudWizard\Models\BaseModel;

/**
 * @property OperationAttributes $a
 * @mixin OperationAttributes
 */
class Operation extends BaseModel
{
    public const RESOURCE_NAME = 'operations'; // optional but recommended
    public const WITH_RELATIONS = [
        'client',
        'products',
    ];
    protected array $ignoreUpdateFor = [
        'client_id',
        'currency',
        'value',
        'created_at',
    ];
    protected $table = 'operations'; // optional but recommended
    protected $fillable = [
        'parent_id',
        'client_id',
        'currency',
        'value',
        'created_at',
        'updated_at',
    ];

    public function client(): BelongsTo
    {
        return $this->belongsTo(Client::class, 'client_id', 'id', __FUNCTION__);
    }

    public function products(): HasManyThrough
    {
        return $this->hasManyThrough(
            Product::class,
            OperationProductPivot::class,
            'operation_id',
            'id',
            'id',
            'product_id'
        );
    }

    /**
     * Optional for its return type to be used for autocomplete
     * can be replaced with a @var OperationFrozenAttributes $dto
     * see below latest update of this article about lazy object in PHP >= 8.4
     */
    public function getFrozen(): OperationFrozenAttributes
    {
        // parent will include also the loaded relations into the attributes
        // same as new OperationFrozenAttributes((clone $this)->forceFill($this->toArray()));
        return parent::getFrozen(); 
        
        // just attributes without loaded relations
        // return new OperationFrozenAttributes((clone $this)->forceFill($this->attributesToArray())); 
    }
}

With Attributes class:

<?php

namespace App\Models\Attributes;

use MacropaySolutions\LaravelCrudWizard\Models\Attributes\BaseModelAttributes;

/**
 * @mixin OperationFrozenAttributes
 */
class OperationAttributes extends BaseModelAttributes
{
}

And with Frozen Attributes class:

<?php

namespace App\Models\Attributes;

use MacropaySolutions\LaravelCrudWizard\Models\Attributes\BaseModelFrozenAttributes;

/**
 * @property int $id
 * @property ?int $parent_id
 * @property int client_id
 * @property string currency
 * @property string value
 * @property ?string created_at
 * @property ?string updated_at
 */
class OperationFrozenAttributes extends BaseModelFrozenAttributes
{
}

You can use it like this:

<?php

namespace App\Services;

use App\Models\Attributes\ClientFrozenAttributes;
use App\Models\Attributes\ProductFrozenAttributes;
use App\Models\Operation;
use MacropaySolutions\LaravelCrudWizard\Services\BaseResourceService;

class OperationsService extends BaseResourceService
{
    /**
     * @inheritDoc
     */
    protected function setBaseModel(): void
    {
        $this->model = new Operation();
    }

    public function demoOperationFrozenAttributes(): void
    {
         /** @var Operation $model */
         $model = Operation::query()->with(['client', 'products'])->firstOrFail();
         $dto = $model->getFrozen();
         echo $dto->client_id; // has autocomplete - will print for example 1
         $dto->client_id = 4; // Exception: Dynamic properties are forbidden.

         if (isset($dto->client)) {
             /** @var ClientFrozenAttributes $client */
             // $client will be an stdClass that has autocomplete like a ClientFrozenAttributes
             $client = $dto->client;
             echo $client->name; // has autocomplete - will print for example 'name'
             $client->name = 'text'; // NO Exception
             echo $client->name; // will print 'text'
             // $client changes can happen, but they will not be persisted in the $dto ($client is a stdClass clone)
             echo $dto->client->name; // will print 'name'
             echo $dto->client->name = 'text'; // will print 'text'
             echo $dto->client->name; // will print 'name'
         }

         foreach (($dto->products ?? []) as $k => $product) {
             /** @var ProductFrozenAttributes $product */
             // $product will be an stdClass that has autocompletes like a ProductFrozenAttributes
             echo $product->value; // has autocomplete - will print for example 1
             $product->value = 2; // NO Exception
             echo $product->value; // will print 2
             // $product changes can happen, but they will not be persisted in the $dto ($product is a stdClass clone)
             echo $dto->products[$k]->value; // will print 1
             echo $dto->products[$k]->value = 2; // will print 2
             echo $dto->products[$k]->value; // will print 1
         }
    }

    public function demoOperationAttributes(): void
    {
         $this->model->incrementing = 0; // sets the public declared property to 0 because it is not type hinted
         $this->model->setAttribute('incrementing', 0); // sets the model attribute
         $this->model->a->incrementing = 0; // sets the model attribute

         $this->model->exists = 0; // sets the public declared property to 0 because it is not type hinted
         $this->model->setAttribute('exists', 0); // sets the model attribute
         $this->model->a->exists = 0; // sets the model attribute

         echo $this->model-a->value; // has autocomplete - will print for example 1
         echo $this->model-a->value = 10; // has autocomplete - will print 10
         echo $this->model->value; // has autocomplete - will print 10
    }
}

Good news is that you can use laravel-crud-wizard-generator to generate almost all the needed classes for this.

The bad news is that the FrozenAttributes Class is not auto-generated but it can be easily created from the AttributesClass.

More details can be found in the lib’s Readme file.

Sending collection to blade

Route::get('/', fn (): View => \view('welcome', [
    /** Illuminate\Support\Collection<int, OperationFrozenAttributes> */
    'operations' => Operation::query()->limit(15)->get()
        ->map(fn (Operation $o): OperationFrozenAttributes => $o->getFrozen())
]));

Using Lazy Object in PHP ≥ 8.4

    public function getFrozen(): OperationFrozenAttributes
    {
        $reflector = new ReflectionClass(OperationFrozenAttributes::class);
        $t = $this;

        return $reflector->newLazyGhost(function (OperationFrozenAttributes $object) use ($t): void {
            $object->__construct((clone $t)->forceFill($t->toArray()));
        });
    }

Version 4.0.0 introduced a breaking change that will eliminate (also for the future) the clashes between the object property and the attribute/column and the relation if used like:

// access php property
$model->exists;
// access column
$model->a->exists;
// access cached/future cached relation
$model->r->exists;
// get relation object
$model->exists();
$model->r->exists();
$model->a->exists(); // a and r forward calls to the model via __call

Also, if you want autocomplete and you don’t want to create child classes from BaseModelAttributes and BaseModelRelations you can just put in your models:

/**
 * @property static $a
 * @property static $r
 */