How to get your Laravel app from 0 to 9 with Larastan
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:
- Run
composer require larastan/larastan:^2.0 --dev
- Add a
phpstan.neon
orphpstan.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:
- basic checks, unknown classes, unknown functions, unknown methods called on
$this
, wrong number of arguments passed to those methods and functions, always undefined variables - possibly undefined variables, unknown magic methods and properties on classes with
__call
and__get
- unknown methods checked on all expressions (not just
$this
), validating PHPDocs - return types, types assigned to properties
- basic dead code checking - always false
instanceof
and other type checks, deadelse
branches, unreachable code after return; etc. - checking types of arguments passed to methods and functions
- report missing typehints
- 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
- report calling methods and accessing properties on nullable types
- be strict about the
mixed
type - the only allowed operation you can do with it is to pass it to anothermixed
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:
- Using the
assert
method and thestring
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;
}
- Using both
string
andboolean
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.
driesvints, alanmoe, rawphp, ricventu, sindhani, abdwhidd, itszun, rampazoatila liked this article
Other articles you might like
Laravel Custom Query Builders Over Scopes
Hello 👋 Alright, let's talk about Query Scopes. They're awesome, they make queries much easier to r...
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....
🍣 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...
The Laravel portal for problem solving, knowledge sharing and community building.
The community