Pint, Rector, Larastan, and Pest: Essentials for Success
Part of the series Empower and Enhance Your VitePress Blog with a Laravel API
Now that the Laravel project is in place and its structure is familiar, it is time to make the codebase easier to maintain.
As the project grows, consistent formatting, automated refactoring, static analysis, and tests will help us move quickly without losing confidence. In this article, we will configure four tools that support that workflow: Pint, Rector, Larastan, and Pest.
- Pint formats PHP code according to a consistent style. In the JavaScript ecosystem, it is closest to Prettier or ESLint's formatting rules.
- Rector refactors PHP code automatically. It can modernize syntax, remove deprecated patterns, and apply project-wide improvements.
- Larastan performs static analysis tailored to Laravel. It catches many potential problems before the code runs.
- Pest is a testing framework focused on simple, expressive tests. It is the closest PHP equivalent to tools such as Jest in the JavaScript ecosystem.
Using these tools from the start keeps the codebase easier to maintain and gives future contributors a clear, consistent foundation. It also saves time as the project grows.
Pint
Pint comes pre-installed with Laravel, so there is nothing to install. We only need to configure it and add a Composer script for formatting the code.
Create a pint.json file in the project root. This file lets us choose the rules that should be applied across the codebase.
{
"preset": "laravel",
"rules": {
"array_push": true,
"backtick_to_shell_exec": true,
"date_time_immutable": true,
"declare_strict_types": true,
"lowercase_keywords": true,
"lowercase_static_reference": true,
"final_class": true,
"final_internal_class": true,
"final_public_method_for_abstract_class": true,
"fully_qualified_strict_types": true,
"global_namespace_import": {
"import_classes": true,
"import_constants": true,
"import_functions": true
},
"mb_str_functions": true,
"modernize_types_casting": true,
"new_with_parentheses": false,
"no_superfluous_elseif": true,
"no_useless_else": true,
"no_multiple_statements_per_line": true,
"ordered_class_elements": {
"order": [
"use_trait",
"case",
"constant",
"constant_public",
"constant_protected",
"constant_private",
"property_public",
"property_protected",
"property_private",
"construct",
"destruct",
"magic",
"phpunit",
"method_abstract",
"method_public_static",
"method_public",
"method_protected_static",
"method_protected",
"method_private_static",
"method_private"
],
"sort_algorithm": "none"
},
"ordered_interfaces": true,
"ordered_traits": true,
"protected_to_private": true,
"self_accessor": true,
"self_static_accessor": true,
"strict_comparison": true,
"visibility_required": true
}
}Let's break down the configuration:
preset: This option allows you to choose a preset configuration for Pint. In this case, we are using thelaravelpreset, which comes with a set of recommended rules for Laravel projects.rules: This section contains the specific rules we want to apply to our codebase. Each rule can be set totrueorfalse, depending on whether we want to enable or disable it.array_push: Enforces the use of array push syntax instead of array merging.backtick_to_shell_exec: Converts backticks to theshell_execfunction.date_time_immutable: Enforces the use of immutable date and time objects.declare_strict_types: Enforces the use of strict types in PHP files.lowercase_keywords: Enforces the use of lowercase keywords in PHP.lowercase_static_reference: Enforces the use of lowercase static references in PHP.final_class: Enforces the use of final classes.final_internal_class: Enforces the use of final classes for internal classes.final_public_method_for_abstract_class: Enforces the use of final public methods for abstract classes.fully_qualified_strict_types: Enforces the use of fully qualified strict types in PHP files.global_namespace_import: Enforces the use of global namespace imports for classes, constants, and functions.mb_str_functions: Enforces the use of multibyte string functions.modernize_types_casting: Enforces the use of modern type casting in PHP.new_with_parentheses: Enforces the use of parentheses when creating new objects.no_superfluous_elseif: Enforces the removal of superfluous elseif statements.no_useless_else: Enforces the removal of useless else statements.no_multiple_statements_per_line: Enforces the use of a single statement per line.ordered_class_elements: Enforces the ordering of class elements, such as traits, constants, properties, and methods.ordered_interfaces: Enforces the ordering of interfaces.ordered_traits: Enforces the ordering of traits.protected_to_private: Enforces the use of private visibility for properties and methods.self_accessor: Enforces the use of self accessors for properties and methods.self_static_accessor: Enforces the use of self static accessors for properties and methods.strict_comparison: Enforces the use of strict comparison operators.visibility_required: Enforces the use of visibility keywords for properties and methods.
Note
If you have questions about any of these rules, feel free to ask in the comments.
Now, let's add a custom command to lint our code. We will add a lint command to our composer.json file:
{
"scripts": {
"lint": "pint"
}
}From now on, we can run composer lint to format the code. Pint will process the files in the app, config, database, and routes folders and keep their style consistent.
You should see something like this:
➜ composer lint
> pint
✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Laravel
FIXED ......................................................................................................................... 26 files, 26 style issues fixed
✓ app/Http/Controllers/Controller.php declare_strict_types, blank_line_after_opening_tag
✓ app/Models/User.php final_class, declare_strict_types, blank_line_after_opening_tag
✓ app/Providers/AppServiceProvider.php final_class, declare_strict_types, blank_line_after_opening_tag
✓ bootstrap/app.php declare_strict_types, blank_line_after_opening_tag
✓ bootstrap/providers.php declare_strict_types, blank_line_after_opening_tag
✓ config/app.php declare_strict_types, blank_line_after_opening_tag
✓ config/auth.php declare_strict_types, blank_line_after_opening_tag
✓ config/cache.php declare_strict_types, blank_line_after_opening_tag
✓ config/database.php declare_strict_types, blank_line_after_opening_tag
✓ config/filesystems.php declare_strict_types, blank_line_after_opening_tag
✓ config/logging.php declare_strict_types, blank_line_after_opening_tag
✓ config/mail.php declare_strict_types, blank_line_after_opening_tag
✓ config/queue.php declare_strict_types, blank_line_after_opening_tag
✓ config/services.php declare_strict_types, blank_line_after_opening_tag
✓ config/session.php declare_strict_types, blank_line_after_opening_tag
✓ database/factories/UserFactory.php final_class, self_static_accessor, declare_strict_types, blank_line_after_opening_tag
✓ database/migrations/0001_01_01_000000_create_users_table.php class_definition, declare_strict_types, blank_line_after_opening_tag, braces_position
✓ database/migrations/0001_01_01_000001_create_cache_table.php class_definition, declare_strict_types, blank_line_after_opening_tag, braces_position
✓ database/migrations/0001_01_01_000002_create_jobs_table.php class_definition, declare_strict_types, blank_line_after_opening_tag, braces_position
✓ database/seeders/DatabaseSeeder.php final_class, declare_strict_types, blank_line_after_opening_tag
✓ public/index.php declare_strict_types, blank_line_after_opening_tag
✓ routes/console.php declare_strict_types, blank_line_after_opening_tag
✓ routes/web.php declare_strict_types, blank_line_after_opening_tag
✓ tests/Feature/ExampleTest.php declare_strict_types, blank_line_after_opening_tag
✓ tests/TestCase.php declare_strict_types, blank_line_after_opening_tag
✓ tests/Unit/ExampleTest.php declare_strict_types, blank_line_after_opening_tagThe output shows each file and the rules that Pint applied. A ✓ means that Pint changed the file; a . means that the file was already formatted correctly.
We will also add a test:lint command for CI. Instead of changing files, it checks the formatting and exits with an error if a file needs changes.
{
"scripts": {
"lint": "pint",
"test:lint": "pint --test"
}
}Perfect!
Rector
The second tool is Rector, which complements Pint. Pint changes formatting, while Rector changes the structure and behavior of the code according to its rules. For example:
class SomeClass
{
public function getValue(int $number)
{
if ($number) {
return 100;
}
return 500;
}
}Would be refactored to this:
final class SomeClass
{
public function getValue(int $number): int
{
return $number ? 100 : 500;
}
}By applying the ReturnTypeFromStrictTernaryRector rule.
Let's install Rector with Composer:
composer require rector/rector --devCreate a rector.php configuration file:
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([
__DIR__.'/app',
__DIR__.'/bootstrap/app.php',
__DIR__.'/config',
__DIR__.'/database',
__DIR__.'/public',
])
->withPreparedSets(
deadCode: true,
codeQuality: true,
typeDeclarations: true,
privatization: true,
earlyReturn: true,
strictBooleans: true,
)
->withPhpSets();This configuration is a little more involved than Pint's. Here is what each part does:
declare(strict_types=1): This line enables strict typing in the file, ensuring that PHP enforces type declarations.use Rector\Config\RectorConfig: This line imports theRectorConfigclass, which is used to configure Rector.return RectorConfig::configure(): This line starts the configuration process for Rector.->withPaths([...]): This method specifies the paths to the directories and files that Rector should analyze and refactor. In this case, we are including theapp,bootstrap/app.php,config,database, andpublicdirectories.->withPreparedSets(...): This method allows you to enable predefined sets of rules for Rector. In this case, we are enabling several sets:deadCode: Removes dead code from the project.codeQuality: Improves code quality by applying various best practices.typeDeclarations: Adds type declarations to functions and methods.privatization: Makes properties and methods private when possible.earlyReturn: Applies early return patterns to improve readability.strictBooleans: Enforces strict boolean comparisons.
->withPhpSets(): This method enables a set of rules specifically designed for PHP.
Now, let's add a custom command to our composer.json file:
{
"scripts": {
"refactor": "rector"
}
}We can now run composer refactor. Rector will inspect the files in app, bootstrap/app.php, config, database, and public and apply the configured transformations. You should see output similar to this:
➜ composer refactor
> rector
20/20 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
8 files with changes
====================
1) database/migrations/0001_01_01_000001_create_cache_table.php:12
---------- begin diff ----------
@@ @@
*/
public function up(): void
{
- Schema::create('cache', function (Blueprint $table) {
+ Schema::create('cache', function (Blueprint $table): void {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
- Schema::create('cache_locks', function (Blueprint $table) {
+ Schema::create('cache_locks', function (Blueprint $table): void {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
----------- end diff -----------
Applied rules:
* AddClosureVoidReturnTypeWhereNoReturnRector
# ...Note
The output is shortened here. Your terminal should list every file that Rector modified.
The output lists each modified file, shows a diff, and identifies the rules that were applied.
We will also add a test:refactor command for CI. It runs Rector in dry-run mode and reports changes without modifying files.
{
"scripts": {
"refactor": "rector",
"test:refactor": "rector --dry-run"
}
}Larastan
Install Larastan with Composer:
composer require --dev "larastan/larastan:^3.0"Create a phpstan.neon file:
includes:
- vendor/larastan/larastan/extension.neon
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
parameters:
level: 6
paths:
- app
- config
- bootstrap
- database/factories
- routesThis configuration tells Larastan what to load and which parts of the project to analyze:
includes: This section includes additional configuration files that extend the functionality of Larastan. In this case, we are including thelarastan/larastan/extension.neonfile and thephpstan/phpstan/conf/bleedingEdge.neonfile.parameters: This section contains the main configuration parameters for Larastan.level: This parameter sets the strictness of the analysis. Higher levels catch more potential issues but can also produce more false positives. Level 6 is a reasonable balance for this project, but starting at level 2 and increasing it gradually is also a good approach.paths: This parameter specifies the paths to the directories and files that Larastan should analyze. In this case, we are including theapp,config,bootstrap,database/factories, androutesdirectories.
Now, let's add a custom command to our composer.json file:
{
"scripts": {
"test:types": "phpstan analyse"
}
}Unlike Pint and Rector, Larastan isn't automatic and can't fix the code for you. Instead, it will raise errors and warnings that you need to fix manually.
Run composer test:types to analyze the files in app, config, bootstrap, database/factories, and routes. Larastan reports problems that must be fixed manually. You should see output similar to this:
➜ composer test:types
> phpstan analyse
Note: Using configuration file /Users/esoub/dev/p/mimram/phpstan.neon.
20/20 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
------ ---------------------------
Line routes/console.php
------ ---------------------------
:9 Undefined variable: $this
🪪 variable.undefined
------ ---------------------------
[ERROR] Found 1 error
Script phpstan analyse handling the test:types event returned with error code 1The error comes from the default command in routes/console.php, which we do not need for this project. Remove that command to fix it.
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); Run composer test:types again:
➜ composer test:types
> phpstan analyse
Note: Using configuration file /Users/esoub/dev/p/mimram/phpstan.neon.
20/20 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
[OK] No errorsPest
The last tool is Pest, a PHP testing framework focused on simple, expressive tests. Pest is the default testing framework in a new Laravel project, so there is nothing to install.
We will configure it and add a few custom commands to composer.json.
First, let's edit the phpunit.xml file:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Http">
<directory>tests/Http</directory>
</testsuite>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
<directory>config</directory>
<directory>routes</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>This configuration defines the test suites, the application source files, and the environment used during tests:
<?xml version="1.0" encoding="UTF-8"?>: This line specifies the XML version and encoding.<phpunit ...>: This line starts the PHPUnit configuration.xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance": This attribute specifies the XML namespace for the schema instance.xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd": This attribute specifies the location of the XML schema for PHPUnit.bootstrap="vendor/autoload.php": This attribute specifies the bootstrap file for PHPUnit, which is responsible for loading the necessary dependencies.colors="true": This attribute enables colored output in the console.<testsuites>: This section defines the test suites for the project.<testsuite name="Http">: This line defines a test suite named "Http".<directory>tests/Http</directory>: This line specifies the directory containing the tests for the "Http" test suite.<testsuite name="Unit">: This line defines a test suite named "Unit".<directory>tests/Unit</directory>: This line specifies the directory containing the tests for the "Unit" test suite.<source>: This section defines the source directories for the project.<include>: This section includes the specified directories in the source.<directory>app</directory>: This line specifies theappdirectory as a source directory.<directory>config</directory>: This line specifies theconfigdirectory as a source directory.<directory>routes</directory>: This line specifies theroutesdirectory as a source directory.<php>: This section defines the PHP environment variables for the project.<env name="APP_ENV" value="testing"/>: This line sets theAPP_ENVenvironment variable totesting.<env name="APP_MAINTENANCE_DRIVER" value="file"/>: This line sets theAPP_MAINTENANCE_DRIVERenvironment variable tofile.<env name="BCRYPT_ROUNDS" value="4"/>: This line sets theBCRYPT_ROUNDSenvironment variable to4.<env name="CACHE_STORE" value="array"/>: This line sets theCACHE_STOREenvironment variable toarray.<env name="DB_CONNECTION" value="sqlite"/>: This line sets theDB_CONNECTIONenvironment variable tosqlite.<env name="DB_DATABASE" value=":memory:"/>: This line sets theDB_DATABASEenvironment variable to:memory:.<env name="MAIL_MAILER" value="array"/>: This line sets theMAIL_MAILERenvironment variable toarray.<env name="PULSE_ENABLED" value="false"/>: This line sets thePULSE_ENABLEDenvironment variable tofalse.<env name="QUEUE_CONNECTION" value="sync"/>: This line sets theQUEUE_CONNECTIONenvironment variable tosync.<env name="SESSION_DRIVER" value="array"/>: This line sets theSESSION_DRIVERenvironment variable toarray.<env name="TELESCOPE_ENABLED" value="false"/>: This line sets theTELESCOPE_ENABLEDenvironment variable tofalse.
These variables give the tests a predictable environment, which reduces flakiness and keeps the test setup simple.
Because we changed the integration-test folder from Feature to Http, rename tests/Feature to tests/Http.
mv tests/Feature/** tests/Http/** && sed -i '' 's/Feature/Http/g' tests/Pest.phpIn the tests folder, we now have two folders and two files:
tests/Http: This folder contains the integration tests for the application.tests/Unit: This folder contains the unit tests for the application.tests/Pest.php: This file contains the Pest configuration.tests/TestCase.php: This file contains the base test case for the application.
Finally, let's add some custom commands to our composer.json file:
{
"scripts": {
"test:type-coverage": "pest --type-coverage --min=100",
"test:unit": "pest --parallel --coverage --min=100",
"test": [
"@test:lint",
"@test:refactor",
"@test:types",
"@test:type-coverage",
"@test:unit"
]
}
}These commands do the following:
test:type-coverage: Runs the tests with type coverage and requires 100% coverage through--min=100.test:unit: Runs the tests with line coverage and requires every line to be covered through--coverageand--min=100.test: Runs linting, refactoring, type checking, type coverage, and the test suite. This is the command we will use in CI.
To have the command test:type-coverage working, we need to install a plugin for Pest:
composer require pestphp/pest-plugin-type-coverage --devNote
Code coverage requires xdebug.
Final Thoughts
With Pint, Rector, Larastan, and Pest configured, the project now has a repeatable quality workflow. Formatting, refactoring, static analysis, and tests can support us as the codebase grows instead of becoming cleanup work we postpone.
The Laravel foundation is ready, so we can start building features on top of it. Next, we will add GitHub authentication with Socialite so users can identify themselves before they manage comments.
Thanks for reading! My name is Estéban, and I love to write about web development and the human journey around it.
I've been coding for several years now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.
If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.
I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below, it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!
Discussions
Add a Comment
You need to be logged in to access this feature.