morningtrain/laravel-resources

Resources system for Laravel

4.23.0 2022-04-13 12:11 UTC

README

Operations are a set of actions on the server that handles all logic from setting up routes to handling client requests.

A simple operation could be an index operation for getting all users and returning them to the client.

Another use-case could be triggering an action on an Eloquent model. When doing this, one would always have to validate user input, fetch the model, execute the method and then perhaps return something meaningful to the user.

This is similar to a simple operation where we need to return a single instance of a model to the user (a traditional read call) - but it contains some additional logic to handle method execution.

A delete operation is similar, in that it first fetches the model and the trigger the delete method on it.

Install

Via Composer

$ composer require morningtrain/laravel-resources
$ php artisan vendor:publish --provider="MorningTrain\Laravel\Resources\LaravelResourcesServiceProvider"

Configuration

Register your resources and operations in config/resources.php:

The first level key corresponds to namespace, and value is an array of your resource classes. You can give resources custom names with array keys, and nest arrays of resources. Just make sure all items have unique keys.

If no key is provided for a resource, the Str::snake(class_basename($resource)) is used. This means if you have a class called User and another resource with the key "user" in the same namespace, they will collide.

Example:

'api' => [
    \App\Operations\Api\User::class,
    'custom_user' => \App\Operations\Api\User::class,
    
    'nested' => [
        \App\Operations\Api\User::class,
        'custom_user' => \App\Operations\Api\User::class,
        
        'deep_nested' => [
            \App\Operations\Api\User::class,
            'custom_user' => \App\Operations\Api\User::class,
        ],
    
    /*
     * These two will not work together - non-unique resource name:
     *  \App\Operations\Api\MyResource::class,
     *  'my_resource' => \App\Operations\Api\User::class,
     */
],

Examples

Index Operation

The purpose of the Index operation is to retrieve a list of model entries and return the list as JSON.

This code example is taken from the package and will illustrate how the Index operation is implemented.

<?php

namespace MorningTrain\Laravel\Resources\Operations\Eloquent;

use MorningTrain\Laravel\Resources\Support\Contracts\EloquentOperation;
use MorningTrain\Laravel\Resources\Support\Pipes\Eloquent\QueryToCollection;
use MorningTrain\Laravel\Resources\Support\Pipes\Eloquent\QueryModel;
use MorningTrain\Laravel\Resources\Support\Pipes\TransformToView;

class Index extends EloquentOperation
{

    const ROUTE_METHOD = 'get';

    protected function beforePipes()
    {
        return [

            /**
             * Takes filters and model name as parameters and returns a new query object
             */
            QueryModel::create()->model($this->model)->filters($this->getCachedFilters()),

            /**
             * Trigger `get` on the query and returns the resulting collection
             */
            QueryToCollection::create(),

            /**
             * Transform the collection by applying any appends to each model in the entry
             */
            TransformToView::create()->appends($this->appends, $this->overwrite_appends),
        ];
    }

}

On a project level, the Index class can be either implemented in a Resource or as a single Operation.

The following example is an EloquentResource implementing a single Index operation. The operation is already added in the base class, so it is not really neccesary to have the static $operations property.

In our example, users are returned with a pagination filter applied.

<?php

namespace App\Http\Operations\Api;

use MorningTrain\Laravel\Resources\Support\Contracts\EloquentResource;
use MorningTrain\Laravel\Resources\Operations\Eloquent\Index;
use MorningTrain\Laravel\Filters\Filter;
use App\Models\User as Model;

class Users extends EloquentResource
{

    protected $model = Model::class;
    
    public static $operations = [
        Index::class,
    ];

    public function getFilters(){
        return [
            Filter::paginate(),
        ];
    }

}

The following example is similar to the one above, but will have the operation as a single class.

<?php

namespace App\Http\Operations\Api\Users;

use MorningTrain\Laravel\Resources\Operations\Eloquent\Index as Operation;
use MorningTrain\Laravel\Filters\Filter;
use App\Models\User as Model;

class IndexUsers extends Operation
{

    protected $model = Model::class;

    public function getFilters(){
        return [
            Filter::paginate(),
        ];
    }

}

Design principles

Much of what we do with operations can be achieved using a traditional MVC approach. It is however easy for controller logic to degrade into duplicated and badly structured code.

Our goal when implementing operations in our setup is to gain a way to greatly increase code reuse. When we have found a good solution for solving a specific task, why not reuse it for all similar tasks?

Resources

What we call a resource in this context, is essentially a collection or group of operations. It is inside of a resource, that each and every operation is initialised and configured.

Configuration

Having operations being configurable means that we can build a multipurpose operation that is used in similar situations but with slightly different behaviour.

A common example could be that of a typical index operation to query an Eloquent model based on a set of filters and return a collection to the user.

In this case, our Index operation is configured to use a specific model class (as would be the case with all Eloquent operations) and an array of filters.

So far we have only been mentioning API operations, but an operation could also be a normal page request. In this case it would be configured to have a prettier route path and for instance to return a given view. For most of our use cases, the page operation renders a view that outputs a desired React component.

It is a given that all operations should be configured to be protected by a permission layer in order to control access. Using Laravel, we are in the end utilizing the underlying policy and gate system to control access. It is spiced up with an extra permission laravel package to create the needed models and database structures to support roles and permissions.

Micro tasks using pipelines

With the release of version 2.x of our Laravel package, the main logic of operations are split into multiple smaller tasks in order to allow for futher code reuse. This is done using the Laravel pipeline setup that are also used internally by Laravel for handling middlewares.

Documentation for Pipelines in Laravel 5.7 can be found here.

This allows for a higher degree of customization between similar operations while keeping base logic decoupled.

Every minor task is called a Pipe to reflect it being a part of an operation pipeline.

Examples of pipes:

  • Validate: Validates the incoming http request using Laravel compliant validation rules
  • Filter query: It allows for a DB query builder instance to be filtered based on HTTP request variables.
  • Prepare response payload: The return value of the main operation logic is uniformly transformed into a payload JSON object.

Having operations structured as a pipeline also keeps actual code/logic out of the operations themselves. What is left is more or less a configurable class. To make changes and adaptations to how an operation works, one would only have to copy/extend the operations and changes to the pipeline. In most cases, no addition code is required.

Pipes

TriggerOnModel

The TriggerOnModel pipe expects the data returned by the previous pipe to be a Eloquent Model. It will trigger a method on the model or the provided closure with the current model instance.

It can be used like this:

\MorningTrain\Laravel\Resources\Support\Pipes\Eloquent\TriggerOnModel::create()
    ->trigger('someModelMethod')

Or like this using a closure:

\MorningTrain\Laravel\Resources\Support\Pipes\Eloquent\TriggerOnModel::create()
    ->trigger(
        fn(Model $model) => $model->someModelMethod()
    )

The Action eloquent operation is basically a Read operation with a TriggerOnModel pipe.

TriggerOnModelsInCollection

The TriggerOnModelsInCollection pipe is similar to TriggerOnModel. Instead of expecting a model, it expects a collection of models and will execute the trigger for exery model instance in the collection.

QueryToCollection

Triggers get on the query returned by the previous pipe and returns the result.

QueryToModel

Triggers first on the query returned by the previous pipe and returns the result.

QueryToCount

Triggers count on the query returned by the previous pipe and returns the result.

ToResourceView

It expects the previous pipe to return a Model or Collection instance. The data (Model or Collection) will be passed into a Laravel Resource and the resulting array will be returned.

\MorningTrain\Laravel\Resources\Support\Pipes\ToResourceView::create()
    ->view(MyResourceView::class)

Most Eloquent operations are already configured to use this pipe, so it will be possible to configure the resource like this:

public function readOperation(Read $read)
{
    $read->model(MyModel::class)
         ->resourceView(MyModelResource::class);
}

SetEnv

This pipe sets context environment variables. It is configurable using the environment method.

\MorningTrain\Laravel\Resources\Support\Pipes\Context\SetEnv::create()
    ->environment(['env' => 'variable'])

The environment method is a proxy of Context::env, which means that it will also be able to take a closure that is executed when the ENV is generated.

BladeView

The BladeView pipe returns a blade view matching the configuration.

\MorningTrain\Laravel\Resources\Support\Pipes\Pages\BladeView::create()
    ->path('path.to.blade.view')
    ->parameters(['blade' => 'parameters'])

RespondWithPageEnv

The RespondWithPageEnv pipe return the current ENV from Context as the response if it is an AJAX request. It can be used to get the ENV of a page by calling its route using Ajax.

\MorningTrain\Laravel\Resources\Support\Pipes\Pages\RespondWithPageEnv::create()

Credits

This package is developed and actively maintained by Morningtrain.

 _- _ -__ - -- _ _ - --- __ ----- _ --_  
(         Morningtrain, Denmark         )
 `---__- --__ _ --- _ -- ___ - - _ --_ ´ 
     o                                   
    .  ____                              
  _||__|  |  ______   ______   ______ 
 (        | |      | |      | |      |
 /-()---() ~ ()--() ~ ()--() ~ ()--() 
--------------------------------------