Support the ongoing development of Laravel.io →
Article Hero Image

How to get your Laravel app from 0 to 9 with Larastan

11 Mar, 2024 7 min read

Photo by Scott Webb on Unsplash

Finding bugs in your Laravel app before it's even executed is possible, thanks to Larastan, which is a wrapper around PHPStan designed to support all the Laravel magic inside static analysis.

Here, I will guide you through the steps of installing Larastan until reaching level 9 in the rules without ignoring anything.

From Larastan's README, to install it, we do the following:

  1. Run composer require larastan/larastan:^2.0 --dev
  2. Add a phpstan.neon or phpstan.neon.dist file in the root folder of your project:
includes:
    - vendor/larastan/larastan/extension.neon

parameters:

    paths:
        - app/

    # Level 9 is the highest level
    level: 5

As you can see, by default, it's set to check with level 5, but we will change it to level 0.

Before continuing, we need to know what is checked on each level by Larastan:

  1. basic checks, unknown classes, unknown functions, unknown methods called on $this, wrong number of arguments passed to those methods and functions, always undefined variables
  2. possibly undefined variables, unknown magic methods and properties on classes with __call and __get
  3. unknown methods checked on all expressions (not just $this), validating PHPDocs
  4. return types, types assigned to properties
  5. basic dead code checking - always false instanceof and other type checks, dead else branches, unreachable code after return; etc.
  6. checking types of arguments passed to methods and functions
  7. report missing typehints
  8. report partially wrong union types - if you call a method that only exists on some types in a union type, level 7 starts to report that; other possibly incorrect situations
  9. report calling methods and accessing properties on nullable types
  10. be strict about the mixed type - the only allowed operation you can do with it is to pass it to another mixed

With those rules in mind, let's say we have this code (for simplicity, everything is in the same file):

Note: The code here was written so I could get the errors I needed in order to show you how to proceed, there are some parts that can be written in more simple ways.

declare(strict_types=1);

use App\Http\Controllers\Controller;
use App\Models\Appointment;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;

class User extends Model
{
    public function appointments()
    {
        return $this->hasMany(Appointment::class);
    }
}

class UserDTO
{
    public function __construct(
        public $name,
        public $is_active,
    ) {
    }

    public function toArray()
    {
        return [
            'name' => $this->name,
            'is_active' => $this->is_active,
        ];
    }
}

class ShowUserQuery
{
    public function run($id)
    {
        $this->doSomething();

        return User::query()
            ->with('appointments')
            ->find($id);
    }
}

class UserController extends Controller
{
    public function show($id, ShowUserQuery $query)
    {
        return response()->json($query->run($id)->toArray());
    }

    public function store(Request $request)
    {
        $request->validate([
            'name' => ['required', 'max:250'],
            'is_active' => ['required', 'boolean'],
        ]);

        if (true) {
            return;
        }

        $isActive = $request->input('is_active');

        $userDTO = new UserDTO(
            $request->input('name'),
            $isActive
        );

        $user = User::create($userDTO->toArray());

        return $user;
    }
}

After running ./vendor/bin/phpstan analyze, we get these errors:

  22     Call to an undefined method ShowUserQuery::doSomething().  
  24     Relation 'appointments' is not found in User model.              

To fix them, we need to remove or define the undefined method, and add the return type for the model relationship, leaving us with this:

class User extends Model
{
    public function appointments(): HasMany
    {
        return $this->hasMany(Appointment::class);
    }
}

class ShowUserQuery
{
    public function run($id)
    {
        return User::query()
            ->with('appointments')
            ->find($id);
    }
}

Until level 4, we don't get any errors because, in this case, we are not breaking the rules for levels 1, 2, and 3. After changing the level to 4, these are the errors we get:

  43     If condition is always true.                           
  47     Unreachable statement - code above always terminates.

To solve this issue, we need to remove the if statement located inside the store method of the controller, leaving the function like this after the fix:

public function store(Request $request)
{
    $data = $request->validate([
        'name' => ['required', 'max:250'],
        'is_active' => ['required', 'boolean'],
    ]);

    $isActive = $request->input('is_active');

    $userDTO = new UserDTO(
        $request->input('name'),
        $isActive
    );

    $user = User::create($userDTO->toArray());

    return $user;
}

For level 5, we are good to go, but for level 6, we get a bunch of errors:

  13     Method User::appointments() return type with generic class                  
         Illuminate\Database\Eloquent\Relations\HasMany does not specify its types:  
         TRelatedModel                                                               
         💡 You can turn this off by setting                                         
            checkGenericClassInNonGenericObjectType: false in your                   
            phpstan.neon.                                                            
  21     Method ShowUserQuery::run() has no return type specified.                   
  21     Method ShowUserQuery::run() has parameter $id with no type specified.       
  31     Method UserController::show() has no return type specified.                 
  31     Method UserController::show() has parameter $id with no type specified.     
  36     Method UserController::store() has no return type specified. 

Let's start fixing the issues. In this level, we need to specify the return and parameter types for everything in the code. For the first error, it's asking us to specify the type of related model in the relationship definition.

After fixing the issues, we are left with this code:

class User extends Model
{
    /**
     * @return HasMany<Appointment>
     */
    public function appointments(): HasMany
    {
        return $this->hasMany(Appointment::class);
    }
}

class UserDTO
{
    public function __construct(
        public string $name,
        public bool $is_active,
    ) {
    }

    /**
     * @return array{name: string, is_active: bool}
     */
    public function toArray(): array
    {
        return [
            'name' => $this->name,
            'is_active' => $this->is_active,
        ];
    }
}

class ShowUserQuery
{
    public function run(int $id): ?User
    {
        return User::query()
            ->with('appointments')
            ->find($id);
    }
}

class UserController extends Controller
{
    public function show(int $id, ShowUserQuery $query): JsonResponse
    {
        return response()->json($query->run($id)->toArray());
    }

    public function store(Request $request): User
    {
        $request->validate([
            'name' => ['required', 'max:250'],
            'active' => ['required', 'boolean'],
        ]);
        $isActive = $request->input('is_active');

        $userDTO = new UserDTO(
            $request->input('name'),
            $isActive
        );

        $user = User::create($userDTO->toArray());

        return $user;
    }
}

For level 7, we are clear, but for 8, sadly, we aren't:

  37     Cannot call method toArray() on User|null.  

To fix it, we have to avoid calling methods or properties on nullable types. Then, the fix here would be:

public function show(int $id, ShowUserQuery $query): JsonResponse
{
    $userArray = $query->run($id)?->toArray() ?? [];

    return response()->json($userArray);
}

Finally, we arrive at the maximum and most restrictive level, which is level 9, and there are some errors related to mixed values. For this scenario, I made the variable $isActive on purpose to show you two ways in which you can fix the same error:

  1. Using the assert method and the string method of the request:
public function store(Request $request): User
{
    $request->validate([
        'name' => ['required', 'max:250'],
        'active' => ['required', 'boolean'],
    ]);
    $isActive = $request->input('is_active');
    assert(is_bool($isActive));

    $userDTO = new UserDTO(
        $request->string('name')->toString(),
        $isActive
    );

    $user = User::create($userDTO->toArray());

    return $user;
}
  1. Using both string and boolean method from the request:
public function store(Request $request): User
{
    $request->validate([
        'name' => ['required', 'max:250'],
        'active' => ['required', 'boolean'],
    ]);

    $userDTO = new UserDTO(
        $request->string('name')->toString(),
        $request->boolean('is_active')
    );

    $user = User::create($userDTO->toArray());

    return $user;
}

Our final code will look like this after doing all the fixes from level 0 to 9 of Larastan/PHPStan.

<?php

declare(strict_types=1);

use App\Http\Controllers\Controller;
use App\Models\Appointment;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class User extends Model
{
    /**
     * @return HasMany<Appointment>
     */
    public function appointments(): HasMany
    {
        return $this->hasMany(Appointment::class);
    }
}

class UserDTO
{
    public function __construct(
        public string $name,
        public bool $is_active,
    ) {
    }

    /**
     * @return array{name: string, is_active: bool}
     */
    public function toArray(): array
    {
        return [
            'name' => $this->name,
            'is_active' => $this->is_active,
        ];
    }
}

class ShowUserQuery
{
    public function run(int $id): ?User
    {
        return User::query()
            ->with('appointments')
            ->find($id);
    }
}

class UserController extends Controller
{
    public function show(int $id, ShowUserQuery $query): JsonResponse
    {
        $userArray = $query->run($id)?->toArray() ?? [];

        return response()->json($userArray);
    }

    public function store(Request $request): User
    {
        $request->validate([
            'name' => ['required', 'max:250'],
            'active' => ['required', 'boolean'],
        ]);

        $userDTO = new UserDTO(
            $request->string('name')->toString(),
            $request->boolean('is_active')
        );

        $user = User::create($userDTO->toArray());

        return $user;
    }
}

With this example, I hope you can get an idea of how to make your app bug-free before it's even executed with the help of Larastan and going all the way up to level 9.

Last updated 3 weeks ago.

driesvints, alanmoe, rawphp, ricventu, sindhani, abdwhidd, itszun, rampazoatila liked this article

8
Like this article? Let the author know and give them a clap!

Other articles you might like

Article Hero Image November 18th 2024

Laravel Custom Query Builders Over Scopes

Hello 👋 Alright, let's talk about Query Scopes. They're awesome, they make queries much easier to r...

Read article
Article Hero Image November 19th 2024

Access Laravel before and after running Pest tests

How to access the Laravel ecosystem by simulating the beforeAll and afterAll methods in a Pest test....

Read article
Article Hero Image November 11th 2024

🍣 Sushi — Your Eloquent model driver for other data sources

In Laravel projects, we usually store data in databases, create tables, and run migrations. But not...

Read article

We'd like to thank these amazing companies for supporting us

Your logo here?

Laravel.io

The Laravel portal for problem solving, knowledge sharing and community building.

© 2024 Laravel.io - All rights reserved.