1
\$\begingroup\$

Introduction

I've improved, IMO, Laravel's base Model and FormRequest. It's working as expected. But, before actually implementing this any further in my application, i want to have my classes checked on any problems i did'nt oversee. Also any suggestions for improvements are welcome. And feel free to use it in your application.


FormRequest

Improvements

  • Easy to use attribute manipulation before validation. Just overwrite the beforeValidation() in your ModelRequest
  • Policy setup is required by default. If no policy is defined, an error will be thrown. This is done because it's often forgot to implement while this is beïng a serious security risk.
  • Rules are now defined on the model.
  • If authorization and validation are required for the request, is defined on the model.

Code

<?php

namespace App\App\Foundation;

use Illuminate\Foundation\Http\FormRequest as BaseFormRequest;

class FormRequest extends BaseFormRequest
{
    /**
     * The attributes that should be modified before passing it to the validator.
     *
     * @var array
     */
    public function beforeValidation()
    {
        return [
            //
        ];
    }

    /**
     * Modify the attributes as defined in beforeValidation() before validation
     *
     * @return \Illuminate\Contracts\Validation\Validator
     */
    protected function getValidatorInstance()
    {
        $this->merge($this->beforeValidation());
        
        return parent::getValidatorInstance();
    }

    /**
     * The rules that should apply on the request which are defined in the model
     * 
     * @return array
     */
    public function rules()
    {
        return $this->getModel()->rules();
    }

    /**
     * Determine if the request is authorized.
     * Authorization should be handled in policies.
     *
     * @return bool
     */
    public function authorize()
    {
        if ($this->getModel()->authorization && is_null(policy(get_class($this->getModel())))) {
            throw new Exception('There are no policies set for '.get_class($this->getModel()).'. You should set the policies in AuthServiceProvider.php');
        }

        return true;
    }
}

Usage:

<?php

namespace App\Http\{namespace};

use App\App\Foundation\FormRequest;
use App\Domain\{namespace};

class {Model}Request extends FormRequest
{
    /**
     * Return a new model instance
     *
     * @return Model
     */
    public function getModel()
    {
        return new {Model};
    }
}

Model

Improvements

  • Defining if validation and authorization are required for the corresponding model. Defaults to true. Setting it to false is usually only done for debugging purposes. You can overwrite the $validation and $authorization properties on your model.
  • If no validation rules are set and the $validation property is not set to false, an error will be thrown when trying to create/update the corresponding model.
  • Validation rules can be set per request type (post, put/patch, delete). If validation needs to apply on both store and update requests, just overwrite the validation() method in your model. Else overwrite the defaultValidation(), storeValidation(), updateValidation() and deleteValidation() methods.
  • Mass assignment protection disabled by default. Only id property is guarded.
  • All date and datetime columns will be automaticly casted to a Carbon instance. No need to set the $dates or $casts property. If you need additional casts, overwrite the casts() method (instead of property).
  • The default connection will be set automaticly. When using relations on multiple different connections, laravel does'nt know which connection to use by default and needs to be explicitly defined on the model.

Code

<?php

namespace App\App\Foundation;

use Illuminate\Database\Eloquent\Model as BaseModel;

class Model extends BaseModel
{
    /**
     * Construct the Model instance and setup the properties
     * 
     * @return void
     */
    public function __construct()
    {
        $this->casts = array_merge(
            $this->getCastableDateColumns(), $this->casts()
        );
        $this->connection = config('database.default');
    }

    /**
     * The attributes that are not mass assignable.
     *
     * @var array
     */
    protected $guarded = [
        'id',
    ];

    /**
     * Determine if validation is required.
     *
     * @var bool
     */
    public $validation = true;

    /**
     * Determine if authorization is required.
     *
     * @var bool
     */
    public $authorization = true;
    
    /**
     * The attributes that should be casted to a given data type.
     *
     * @return array
     */
    public function casts()
    {
        return [];
    }

    /**
     * The rules that should apply on the request.
     * This method is staticly beïng called from within the FormRequest
     * 
     * @return array
     */
    public static function rules() 
    {
        $self = new static;

        if ($self->validation && empty($self->validation())) {
            throw new Exception('There are no validation rules set for '.get_class($self).'. Validation can be disabled by overwriting the $validation property.');
        }

        return $self->validation();
    }

    /**
     * The validation rules that should apply on the request
     * If no rule seperation (store/update) is required, the validation method 
     * can be overwritten so that it returns the validation rules.
     * 
     * @return array
     */
    public function validation()
    {
        $request = request();

        if ($request->method() == 'POST') 
        {
            return array_merge(
                $this->defaultValidation(), 
                $this->storeValidation()
            );
        } 
        elseif ($request->method() == 'PUT' || $request->method() == 'PATCH') 
        {
            return array_merge(
                $this->defaultValidation(), 
                $this->updateValidation()
            );
        } 
        elseif ($request->method() == 'DELETE') 
        {
            return $this->deleteValidation();
        }
    }

    /**
     * The validation rules that should apply on both POST and PUT/PATCH requests
     * Can be used when separating validation logic based on the request type (POST, PUT/PATCH or DELETE)
     * If no seperation is required, the validation() method can be overwritten.
     *
     * @return array
     */
    public function defaultValidation()
    {
        return [];
    }

    /**
     * The validation rules that should only apply on store (POST) requests
     *
     * @return array
     */
    public function storeValidation()
    {
        return [];
    }

    /**
     * The validation rules that should only apply on update (PUT/PATCH) requests
     *
     * @return array
     */
    public function updateValidation()
    {
        return [];
    }

    /**
     * The validation rules that should only apply on delete (DELETE) requests
     *
     * @return array
     */
    public function deleteValidation()
    {
        return [];
    }

    public function getCastableDateColumns()
    {
        $tableColumns = \DB::getDoctrineSchemaManager()->listTableColumns($this->getTable());
        
        $castableDateTypes = [
            'Doctrine\DBAL\Types\DateTimeType' => 'datetime',
            'Doctrine\DBAL\Types\DateType' => 'date',
        ];

        $castableDateColumns = [];

        foreach ($tableColumns as $column) {
            if (in_array(get_class($column->getType()), array_keys($castableDateTypes))) {
                $castableDateColumns[$column->getName()] = $castableDateTypes[get_class($column->getType())];
            }
        }

        return $castableDateColumns;
    }
}

Usage:

<?php

namespace App\Domain\{namespace};

use App\App\Foundation\Model;

class {Model} extends Model
{
    public function validation()
    {
        return [
            //
        ];
    }
}
\$\endgroup\$
1
\$\begingroup\$
  1. What PHP version is this code for? i.e. in PHP 7.4 you can add type hints in the code, hence removes a need for that info in the docblocks.

  2. Not quite sure why you would need getCastableDateColumns(), I never had a need for this in my projects. Also, another alternative might be to use Laravel's built-in functionality - https://laravel.com/docs/7.x/eloquent-mutators#date-mutators

  3. Not quite sure why you would want to format validation data using beforeValidation()? I would be concerned on how data within beforeValidation() is handled in case someone sends malicious information. But, it would be nice to use it for the setup of some sort before validation starts.

  4. Regarding defining rules in the model, I used a similar technique to define fields that can be accessed externally for queries, i.e. I would give FE ability to query field X when endpoint is linked to a specific model.

I found that whilst this technique is nice for a smaller project, for larger projects it proved to be a headache, since some routes might require ability to query field X and some don't. In the end I am now defining a default list that is rather generic & then have a decorator class to extend those defaults.

What I am trying to say is that this will not scale - your models will bloat and will have restricted usage. You'd be better off having a separate class with those rules for specific use cases, which will then extend your base class.

Another issue is that you may want to manipulate those rules, i.e. I you are trying to create a book and additionally to sending data regarding a book you need to send categories, you'd endup with something like below:

public function rules()
{
  $categoryModel = Category::class;

  return $this->getModel()->rules() + [
    'categories' => ['array'],
    'categories.*' => [
      'int',
      "exists:{$categoryModel}",
    ]
  ];
}

Also, what if for whatever reason, one route should allow you to submit one of the fields that you have within the rules array? e.g. users vs admins.

  1. Not quite sure about casts() method either, as you mentioned, Laravel does casting already. In Laravel 8 you can even cast data into DTO's or ValueObjects.

  2. $this->connection = config('database.default'); this is not extendable. For someone who works with multiple databases this is a no go.

FYI Laravel has non-existent support for queries between multiple databases either. Database names are not applied correctly when using eloquent or DB. If you start a query on DB A to perform an action in DB B, Laravel will try performing that action in DB A on the table from DB B.


In summary, I would personally use what you wrote as a base class for form requests.

\$\endgroup\$
13
  • 1
    \$\begingroup\$ Thanks for the effort. I will later today take my time to give a reaction so i can explain the chooices i've maked. \$\endgroup\$ – Ramon Bakker Sep 23 '20 at 13:31
  • \$\begingroup\$ getCastableDateColumns() checks the table structure for date and datetime type of fields. So this way all date fields automaticly gets casted to a Carbon instance without the need of defining it inside the casts-property. In answer to your suggestion of the dates property, its better practice to use the casts-property over the dates-property. \$\endgroup\$ – Ramon Bakker Sep 27 '20 at 11:59
  • \$\begingroup\$ In reaction to point 3, beforeValidation() can be used to format a property before passing it to the validator. This can be handy for in example a postal code. If 1234AB, 1234 AB, 1234ab and 1234 ab all should be accepted, this can be automaticly formatted using strtoupper(str_replace(" ","",$postalcode)) and then passed to the validator. This also helps for data consistency in the database. The user must not be annoyed with errors when we can fix the input very easily. Another example could be to format a phone number before passing it to the validator. \$\endgroup\$ – Ramon Bakker Sep 27 '20 at 12:06
  • \$\begingroup\$ As of the rules inside the model, you can easily overwrite the defaultValidation(), storeValidation(), updateValidation() and deleteValidation(). If thats not enough, just overwrite the validation() method in the mode or overwrite the rules() method in the form request. I dont see anything being wrong using your rules() code example? \$\endgroup\$ – Ramon Bakker Sep 27 '20 at 12:11
  • \$\begingroup\$ The casts() method can be used if additional fields should be casted. If you would overwrite the casts-property, the date fields would'nt be automaticly casted to Carbon instances. Check whats happening in the models constructor. The $casts-property is set based on a array_merge of casts() (should return an array) and getCastableDateColumns() (which returns something like ['start_date', 'end_date']) \$\endgroup\$ – Ramon Bakker Sep 27 '20 at 12:15

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.