sebdesign/laravel-state-machine

multiple graphs/property_paths on same class

MartinP7r opened this issue · 10 comments

Hi, I was wondering if it would be possible to apply more than one statemachine to the class? e.g. when you have two different state variables on the same model?
It seems that right now, getGraph can only set for one graph that in turn is coupled to one property_path.

Actually you can create multiple graphs for a class, and set a different property path. E.g. in your state-machine.php config:

<?php

return [
    'orderStatus' => [
        'class' => App\Order::class,
        'graph' => 'default',
        'property_path' => 'status',
        'metadata' => [],
        'states' => [],
        'transitions' => [],
        'callbacks' => [],
    ],
    'shipmentStatus' => [
        'class' => App\Order::class,
        'graph' => 'shipment',
        'property_path' => 'shipment_status',
        'metadata' => [],
        'states' => [],
        'transitions' => [],
        'callbacks' => [],
    ],
];

So now you can gate the state machine for each graph like this:

$orderState = StateMachine::get($order); // this is the default graph
$shipmentState = StateMachine::get($order, 'shipment'); // this is the shipment graph

I'm not sure how you can use this feature with iben12/laravel-statable, as the trait is allowing only one graph per model.

Hello @MartinP7r , did you find a solution for your problem? I was wondering if you need more assistance or if I can close this issue. Thanks!

@sebdesign thanks a lot and sorry for the late response. I'm going to implement it as you suggested, possible just not using the trait...
a kind of related question, if I may:
Is there a way to apply the same graph to more than one model? E.g. 'class' => [App\Order::class, App\Shipment::class]? Or do I just need to duplicate the config entry for another class?

At the moment you can't specify more than one model in the class in the config.
But you can use an interface or an abstract class in the class instead of a concrete class.

For example, if you have a graph for your App\User model and you want to reuse the same for your App\Admin model, you can specify class => Illuminate\Foundation\Auth\User::class which is the base Authenticatable model that App\User extends, or class => Illuminate\Contracts\Auth\Authenticatable::class which is the AuthenticatableContract interface that the Authenticatable model implements. Of course the App\Admin model must extends the Authenticatable model, or the App\User model, or just implement the AuthenticatableContract.

Does it make sense?

Thanks so much for the more detailed explanation and sorry for the late reaction.

Considering your remarks, is there anything speaking against just specifying App\Model for a state-machine that's being implemented by more than one model, since all models inherit from it?

Or would you advise for implementing an "empty" interface like interface SomeStatable {} and then just specify class => SomeStatable::class (if it's even possible to use an interface in that way).

Sorry for the non-related inquiry. I suppose I should post half of this to stackoverflow instead...

No worries, you can post this kind of questions here.

It is possible to specify an interface instead of a concrete class in a graph.
It can be an empty interface so you can apply it to multiple classes without worrying about the implementation.

You could also specify Illuminate\Database\Eloquent\Model or App\Model, but I would consider this a code smell: Does it mean that every model in your application has the same states and transitions? For example a user, a post, a comment?

I'm curious about what problem you are trying to solve by using a single graph for many classes. Do you have an example? Maybe I can help you find a solution or an idea for refactoring.

Thanks again.

From what you are saying, I'm tending towards a shared empty interface. Your explanation about the code-smelly aspect makes a lot of sense. It's only a couple of models in my project.

Essentially I have several models that are types of requests (BusinessTripRequest, ExpenseReport, etc.) going through and Approval process with variable number of approvers involved. The relevant state goes from Approval through one Final Approval to Approved and can move to Denied at any step. What the models have in common is a one-to-many relationship with an Approval model.

Small sidenote: I'm kind of reluctant to use inheritance (e.g. a Request model from which the concerning models inherit) since the system requirements have been changing a lot to say the least, there's more than just the aspect of the approval process that's common between some of the models in the project and I would prefer composition anyway. But I am struggling a little with the relevant concepts in PHP&Laravel.
Coming from Swift, where class properties can be part of interfaces( aka Protocols), I've been trying to use PHP traits only to realize they can't be used to constrain parameter types. So everything has to be enforced through getter/setter (accessor?) methods?
But Laravel provides these convenient ->attribute_name shortcuts for everything, so it seems like a step back.

edit: thinking about this a little more, maybe I should just decouple the whole Approval-Process into its own model and quasi composition it through a relationship into the other models. Though this creates one more layer of relationships that might be hard to manage...
But on the other hand, guard-related logic might change based on the parent model (BusinessTripReuqest, ExpenseReport, etc...), and would be hard to manage via a Policy if callbacks run on some kind of ApprovalProcess model, separate from them.

I have implemented a similar functionality in the past, regarding multiple approvals and approvers, although not using state machines, but I can give you some insight.

While you cannot define properties on interfaces, you can define them in traits but you can't use them for attributes on Eloquent models, because the model attributes are stored in the attributes array property. So you could define some custom getters/setters. Eloquent uses the getXAttribute and setXattribute convention, that will get called when you use the property access on the model: $object->x.

In addition, the winzou/state-machine uses the symfony/property-access component which has another convention for getters/setters: By default it uses PHP's property access $object->x, but you can define getX and setX methods on the object instead. See the Using Getters and Writing to Objects documentation.

For start, I would create an Approvable interface, which could be empty, or you could define the method for the Approval has-many relationship.

In this example I'm using Symfony's getter/setter notation, but you can use Laravel's, or none if you don't want to enforce this.

interface Approvable
{
    public function approvals(): HasMany;
    public function getApprovalStatus(): string;
    public function setApprovalStatus(string $status): void;
}

For the implementation, I would create an ApprovableTrait trait:
The getter and the setter are delegating to Eloquen't magic getter/setters so they are actually reading/writing the approval_status attribute on the model's attributes property.

trait ApprovableTrait
{
    public function approvals(): HasMany
    {
        return $this->hasMany(Approval::class);
    }

    public function getApprovalStatus(): string
    {
        return $this->approval_status;
    }

    public function setApprovalStatus(string $status): void
    {
        $this->approval_status = $status;
    }
}

Your models then would implement the Approvable interface by using the ApprovableTrait trait:

class BusinessTripRequest extends Model Implements Approvable
{
    use ApprovableTrait;
}

This seems very clean and easy to implement for me. Thanks so much for the detailed example! 😀

Just for confirmation. If I understand this correctly, any logic that I create (state-machine or otherwise), that wants to use the Approvable interface instead of a concrete class, would need to call getApprovalStatus etc. in order to access properties and would not be able to use $object->x unless I otherwise implement it like symfony/property-access does?

Since the Approvable interface is implemented by the eloquent Model you can directly access properties $object->x like any other eloquent model. The getters/setters are just meant to use custom logic for getting/setting the attribute, but that's only if you need to. You don't have to specify any getter/setter in the interface or the trait if you $object->x is working for your case.