Skip to main content
Back to Blog
tutorialJanuary 6, 202512 min min read

Laravel 12 Starter Kit: Testing, Static Analysis & Quality Gates

How to set up a modern Laravel 12 project with comprehensive testing, static analysis, and automated quality gates from day one.

Robert Fridzema

Robert Fridzema

Fullstack Developer

Laravel 12 Starter Kit: Testing, Static Analysis & Quality Gates

Starting a Laravel project "the right way" saves countless hours later. This guide walks through setting up a Laravel 12 project with comprehensive quality tooling: Pest for testing, PHPStan for static analysis, and automated quality gates that prevent bugs from reaching production.

The Goal

By the end, your project will have:

  • Pest for expressive, fast testing
  • PHPStan at level 8+ for type safety
  • Pint for consistent code style
  • Type coverage enforcement
  • Mutation testing with Infection
  • Git hooks that prevent bad commits
  • CI/CD that blocks bad PRs

Initial Setup

Start with a fresh Laravel 12 installation:

composer create-project laravel/laravel my-project
cd my-project

Testing with Pest

Pest provides a clean, expressive syntax for tests. Install it:

composer require pestphp/pest --dev
composer require pestphp/pest-plugin-laravel --dev
php artisan pest:install

Configure Pest

// tests/Pest.php
<?php

uses(Tests\TestCase::class)->in('Feature');
uses(Tests\TestCase::class)->in('Unit');

// Global helpers
function createUser(array $attributes = []): User
{
    return User::factory()->create($attributes);
}

function actingAsUser(array $attributes = []): TestCase
{
    return test()->actingAs(createUser($attributes));
}

Write Expressive Tests

// tests/Feature/OrderTest.php
<?php

use App\Models\Order;
use App\Models\User;

describe('Orders', function () {
    it('can be created by authenticated users', function () {
        $user = createUser();

        $response = $this->actingAs($user)
            ->post('/orders', [
                'product_id' => 1,
                'quantity' => 2,
            ]);

        $response->assertCreated();
        expect(Order::count())->toBe(1);
        expect(Order::first())
            ->user_id->toBe($user->id)
            ->quantity->toBe(2);
    });

    it('requires authentication', function () {
        $this->post('/orders', ['product_id' => 1])
            ->assertUnauthorized();
    });

    it('validates required fields', function () {
        actingAsUser()
            ->post('/orders', [])
            ->assertInvalid(['product_id', 'quantity']);
    });
});

Parallel Testing

Speed up your test suite:

composer require brianium/paratest --dev
// composer.json
"scripts": {
    "test": "pest --parallel"
}

Static Analysis with PHPStan

PHPStan catches bugs before runtime. Install and configure:

composer require phpstan/phpstan --dev
composer require larastan/larastan --dev
# phpstan.neon
includes:
    - vendor/larastan/larastan/extension.neon

parameters:
    level: 8

    paths:
        - app/

    excludePaths:
        - app/Console/Kernel.php

    ignoreErrors:
        # Add specific ignores here if needed

    checkMissingIterableValueType: true
    checkGenericClassInNonGenericObjectType: true
    reportUnmatchedIgnoredErrors: true

Level 8 Tips

Level 8 is strict. Here's how to make it work:

// Bad - PHPStan doesn't know the type
$user = User::find($id);
$user->name; // Error: Cannot access property on mixed

// Good - handle null case
$user = User::findOrFail($id);
$user->name; // OK

// Or with null check
$user = User::find($id);
if ($user !== null) {
    $user->name; // OK
}
// Bad - array without type info
/** @var array $items */
$items = $this->getItems();

// Good - specify array contents
/** @var array<int, OrderItem> $items */
$items = $this->getItems();

// Best - use collections
/** @return Collection<int, OrderItem> */
public function getItems(): Collection
{
    return $this->items;
}

Type Coverage

Enforce that all code has type declarations:

composer require pestphp/pest-plugin-type-coverage --dev
// tests/Pest.php
pest()->extend(Tests\TestCase::class)
    ->beforeAll(function () {
        // Require 100% type coverage
    })
    ->in('Feature', 'Unit');
// composer.json
"scripts": {
    "test:types": "pest --type-coverage --min=100"
}

Fix type issues:

// Before - no return type
public function calculate($amount)
{
    return $amount * 1.21;
}

// After - fully typed
public function calculate(float $amount): float
{
    return $amount * 1.21;
}

Code Style with Pint

Laravel Pint ensures consistent formatting:

composer require laravel/pint --dev
// pint.json
{
    "preset": "laravel",
    "rules": {
        "declare_strict_types": true,
        "final_class": true,
        "ordered_class_elements": true,
        "no_unused_imports": true
    }
}
// composer.json
"scripts": {
    "lint": "pint --test",
    "lint:fix": "pint"
}

Mutation Testing

Mutation testing ensures your tests actually verify behavior:

composer require infection/infection --dev
// infection.json5
{
    "source": {
        "directories": ["app"]
    },
    "logs": {
        "text": "infection.log",
        "summary": "infection-summary.log"
    },
    "mutators": {
        "@default": true
    },
    "minMsi": 80,
    "minCoveredMsi": 90
}
// composer.json
"scripts": {
    "test:mutation": "infection --threads=4"
}

Mutation testing modifies your code and runs tests. If tests still pass after a mutation, you have a gap:

// Your code
public function calculateDiscount(int $quantity): float
{
    if ($quantity >= 10) {
        return 0.1;
    }
    return 0;
}

// Mutation: changed >= to >
if ($quantity > 10) { // Tests should fail!
    return 0.1;
}

// If tests pass, you need a test for quantity = 10
it('applies discount for exactly 10 items', function () {
    expect(calculateDiscount(10))->toBe(0.1);
});

Git Hooks with Captain Hook

Prevent bad commits automatically:

composer require captainhook/captainhook --dev
vendor/bin/captainhook install
// captainhook.json
{
    "pre-commit": {
        "actions": [
            {
                "action": "vendor/bin/pint --test"
            },
            {
                "action": "vendor/bin/phpstan analyse"
            }
        ]
    },
    "pre-push": {
        "actions": [
            {
                "action": "vendor/bin/pest --parallel"
            }
        ]
    }
}

Now:

  • Every commit runs Pint and PHPStan
  • Every push runs the full test suite
  • Bad code never reaches the repo

CI/CD Pipeline

GitHub Actions

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: xdebug

      - name: Install dependencies
        run: composer install --no-progress

      - name: Check code style
        run: vendor/bin/pint --test

      - name: Run PHPStan
        run: vendor/bin/phpstan analyse

      - name: Run tests
        run: vendor/bin/pest --parallel --coverage --min=80

      - name: Check type coverage
        run: vendor/bin/pest --type-coverage --min=100

  mutation:
    runs-on: ubuntu-latest
    needs: tests

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: xdebug

      - name: Install dependencies
        run: composer install --no-progress

      - name: Run mutation tests
        run: vendor/bin/infection --threads=4 --min-msi=80

Complete Scripts

Your composer.json scripts section:

{
    "scripts": {
        "lint": "pint --test",
        "lint:fix": "pint",
        "analyse": "phpstan analyse",
        "test": "pest --parallel",
        "test:coverage": "pest --parallel --coverage --min=80",
        "test:types": "pest --type-coverage --min=100",
        "test:mutation": "infection --threads=4",
        "quality": [
            "@lint",
            "@analyse",
            "@test:coverage",
            "@test:types"
        ],
        "quality:full": [
            "@quality",
            "@test:mutation"
        ]
    }
}

Run composer quality before every PR.

app/
├── Actions/           # Single-purpose action classes
├── Data/              # DTOs (Data Transfer Objects)
├── Enums/             # PHP 8.1+ enums
├── Exceptions/        # Custom exceptions
├── Http/
│   ├── Controllers/
│   ├── Middleware/
│   └── Requests/      # Form requests with validation
├── Models/
├── Policies/
├── Repositories/      # Data access layer (optional)
└── Services/          # Business logic
tests/
├── Feature/           # Integration tests
├── Unit/              # Unit tests
└── Pest.php           # Pest configuration

Key Takeaways

  1. Start strict - It's easier to maintain standards than to add them later
  2. Automate everything - Git hooks and CI catch issues before humans need to
  3. Type everything - PHP 8.4's type system is powerful; use it
  4. Test behavior, not implementation - Mutation testing reveals weak tests
  5. Make it fast - Parallel testing and caching keep feedback loops tight

This setup has served me well across dozens of Laravel projects. The initial investment pays off in fewer bugs, easier refactoring, and confident deployments.


Want help setting up quality tooling for your Laravel project? Get in touch - I've configured this stack for multiple teams.

#Laravel #Testing #PHPStan #Pest #CI/CD
Share: