PHPFixing
  • Privacy Policy
  • TOS
  • Ask Question
  • Contact Us
  • Home
  • PHP
  • Programming
  • SQL Injection
  • Web3.0

Monday, January 17, 2022

[FIXED] Laravel attribute not cast to an object when using spatie/laravel-model-states

 January 17, 2022     laravel, php     No comments   

Issue

I'm having an issue when using the model states library from Spatie. I don't think it's a bug but it's not behaving as expected, although only in one of my controllers. We are using the older version of the code and, for now, cannot update to the latest version.

The problem is the "state" field is not being cast to a Spatie\ModelStates\State derived object and is returned as a string. So when I try to transition to a new state I get the exception: "Call to a member function transitionTo() on string".

However, there are other parts of the code where the same model is used and transitions work correctly, with the state being converted to the correct class. I just can't work out why this one controller is causing problems.

States derived from my own abstract class

<?php

namespace App\States\ShiftPattern;

use Spatie\ModelStates\State;

abstract class ShiftPatternBaseState extends State
{
    public static array $states = [
        Approved::class,
        Draft::class,
        PendingApproval::class,
        Rejected::class,
    ];
}

Even though I register the states in the base class they are also in the same folder. A migration added the status field to the database table

    public function up()
    {
        Schema::table('shift_patterns', function (Blueprint $table) {
            $table->string('status')->default('draft')->after('booking_pay_rate_id');
        });
    }

and my model implements HasStates

class ShiftPattern extends Model
{
    use HasStates, LogsActivity, UserPermissions;
    ...
    public function registerStates(): void
    {
        $this->addState('status', ShiftPatternBaseState::class)
            ->default(Draft::class)
            ->allowTransition([Draft::class, Rejected::class], PendingApproval::class, ToPendingApproval::class)
            ->allowTransition(PendingApproval::class, Approved::class, PendingApprovalToApproved::class)
            ->allowTransition(PendingApproval::class, Rejected::class, ToRejected::class);
    }
    ...
}

The problem is occurring in a controller which I am updating. It currently handles an API call to create a new ShiftPattern and attach it to a Booking model, which works. There is a one-to-many relationship defined between Booking and ShiftPattern.

// CreateShiftPatternRequest has the Booking object ($request->record) and attributes ($request->attributes) to create the ShiftPattern

public function createShiftPattern(CreateShiftPatternRequest $request)
{
    $this->authorize('editShiftPatterns', $request->record);

    $shiftPattern = $request->record->shiftPatterns()->create($request->attributes());

    return $this->reply()->content($shiftPattern, [], $this->getMeta('bookings.shift-pattern.create'));
}

The new ShiftPattern is being created in the "Pending Approvel" state, but some bookings do not require them to be "approved", so I want to move them straight to the "Approved" state.

public function createShiftPattern(CreateShiftPatternRequest $request)
{
    ...
    $shiftPattern = $request->record->shiftPatterns()->create($request->attributes());
    if (!$request->record->booking_must_be_approved) {
        $shiftPattern->transitionTo(Approved::class);
    }
    return $this->reply()->content($shiftPattern, [], $this->getMeta('bookings.shift-pattern.create'));
}

But I keep getting the error "Call to a member function transitionTo() on string" which happens inside the transitionTo call once the state field has been resolved by the library. As I said, in other cases this works fine, but in this one controller the state field is not automatically cast to an object.

I thought the model might not be "booted" correctly to set up the class casts, so I exposed a function to allow the controller to call bootIfNotBooted but inside that it skips the initialisation as it has been booted. Then I tried refreshing and reloading the ShiftPattern from the database, to see if that would solve it:

    ...
    $shiftPattern = $request->record->shiftPatterns()->create($request->attributes());

    // Attempt 1 - refresh model
    $shiftPattern->refresh();
    // Attempt 2 - reload model
    $newShiftPattern = ShiftPattern::find($shiftPattern->id); 
    ...

neither worked, the state field was still being returned as a string.

I also tried creating the model separately from associating it with the Booking, but that did not fix the issue either

    $shiftPattern = ShiftPattern::create($request->attributes());
    if (!$request->record->booking_must_be_approved) {
        $shiftPattern->transitionTo(Approved::class);
    }
    $request->record->shiftPatterns()->save($shiftPattern);
    ...

Does anyone have any idea why this could be happening? I really don't think it's a bug in the ModelStates library since it works in tinker and other sections of my code, it seems to be a Laravel attribute cast issue.

FYI I have also posted this question in the package's github discussion.


Solution

The problem was caused by the status field being filled when the ShiftPattern was created from the attributes passed in:

ShiftPattern::create($request->attributes());

When create is called Laravel constructs a new object and then fills the attributes from the array passed. In this case the caller of the API included the name of the state (pending-approval), so after the class was constructed and the status field set to the default state (as an object) the attribute was overwritten with the string "pending-approval" - hence the error when trying to transition to a new state.

The solutions I see available are:

  1. Don't allow a state field to be fillable - let the object be created with the default state and then transition to the desired state. This method also ensures any transition classes are called.
  2. Fix the state map - in my case the problem was caused because another developer no longer wanted to use the draft state (which would then transition to pending-approval) and jump straight to pending-approval, but the change was made without thinking about its impact. The draft state should have been removed and the code refactored.
  3. Implement a mutator on the state attribute - the mutator would allow state names to be used and translate those to state objects.

For solution 3 the mutator can use the library function resolveStateClass:

public function setStatusAttribute($status)
{
    if (is_string($status)) {
        $stateClass = ShiftPatternBaseState::resolveStateClass($status);
        $status = class_exists($stateClass)
                    ? new $stateClass($this)
                    : (new ReflectionClass(self::getDefaultStateFor('status')))->newInstance($this);
    }

    $this->attributes['status'] = $status;
}

When the state is using a string the base state checks to see if it can be resolved to a class (names or class strings can be used. The string returned from the resolve function is either a fully qualified class name or, if it was not matched to a known state class, the string you passed in.

Checking the string result to see if a class exists means we can either create an instance of that state class, or we create a default state. Either way the attribute on the object is now correct and we can call transitionTo on it.

I'm going to use this mutator as a quick fix for the problem I have but then I need to go back and refactor the code (option 2) to remove the draft state and use the pending-approval state as the default in future.



Answered By - Tony
  • Share This:  
  •  Facebook
  •  Twitter
  •  Stumble
  •  Digg
Newer Post Older Post Home

0 Comments:

Post a Comment

Note: Only a member of this blog may post a comment.

Total Pageviews

Featured Post

Why Learn PHP Programming

Why Learn PHP Programming A widely-used open source scripting language PHP is one of the most popular programming languages in the world. It...

Subscribe To

Posts
Atom
Posts
Comments
Atom
Comments

Copyright © PHPFixing