Laravel Coding Guidelines

These guidelines define how we write and organize code across our Laravel projects. They cover everything from git workflow and code style to architecture patterns and frontend conventions. The goal is consistency: when every developer follows the same rules, code reviews get faster, onboarding becomes easier, and the codebase stays maintainable as it grows. Treat this as a living document — if something is missing or outdated, bring it up with the team.

Git Workflow

Branching

  • Clone the project if you haven't already:

    git clone git@github.com:author/some-repo.git
  • Create a branch for your feature, always referencing the ticket number:

    git checkout -b feature/TN-1337_feature_name
  • Create a pull request targeting develop. Name it: "Draft: TN-1337 New feature X"

  • When the PR is ready for review, remove the "Draft: " prefix.

Commits

  • Commit often and keep commits small and coherent ("atomic commits").

  • Do not mix topics in commits. Each commit should be easily revertable without side effects.

  • Write descriptive commit messages that explain the change.

Code Style & Formatting

Use Laravel Pint for code formatting. Run it before every commit:

./vendor/bin/pintLanguage:shell

String Interpolation

Always use interpolation over concatenation:

// PHP
"{$object->attr}_doc.pdf"

// JS/TS
`${object.attr}_doc.pdf`Language:php

Comments

  • Omit docblocks when type hints and return types are defined. Otherwise use a docblock.

  • Do not add @author attributions.

  • Do not add redundant comments like "Constructor" for __construct.

  • Describe why you do something, not what you do.

Architecture & Business Logic

The Action Pattern

Business logic must live in dedicated Action classes, not in controllers. Each Action encapsulates exactly one task (e.g. creating an order, sending an invoice). This keeps controllers thin and makes logic reusable across controllers, APIs, jobs, and commands.

We use the Laramate Support Package which provides a base Action class with native Laravel queue support.

use Laramate\Support\Action;

class CreateOrderAction extends Action
{
    public function handle(User $user, array $items): Order
    {
        $order = $user->orders()->create([
            'status' => OrderStatus::Pending,
        ]);

        $order->items()->createMany($items);

        return $order;
    }
}Language:php

Action Naming Convention

Name Actions using the Verb + Noun + "Action" pattern. Place them in app/Actions, grouped by domain:

app/Actions/
  Order/
    CreateOrderAction.php
    CancelOrderAction.php
  User/
    RegisterUserAction.php
  Invoice/
    SendInvoiceAction.phpLanguage:shell

Using Actions in Controllers

Controllers orchestrate the request/response cycle. Delegate all business logic to Actions:

class OrderController extends Controller
{
    public function store(StoreOrderRequest $request, CreateOrderAction $action): RedirectResponse
    {
        $order = $action->handle($request->user(), $request->validated('items'));

        return redirect()->route('orders.show', $order);
    }
}Language:php

Actions can also be dispatched to a queue for async processing:

CreateOrderAction::dispatch($user, $items);Language:php

Naming Conventions

Naming things is hard. Stick to these conventions consistently.

Variables

Use camelCase and descriptive names:

// Good
$apiKey = config('services.myservice.key');
$users = User::all();
$user = User::find(1);

// Bad
$u = User::all();
$user = User::all(); // misleading: singular name for a collectionLanguage:php

Model Properties

Model properties use snake_case matching their column names:

// Good
$model->updated_at

// Bad
$model->updatedAtLanguage:php

Classes & Files

// Controllers: Singular PascalCase + "Controller"
OrderController
UserController

// Actions: Verb + Noun + "Action"
CreateOrderAction
SendInvoiceAction

// Form Requests: Verb + Model + "Request"
StoreOrderRequest
UpdateUserRequest

// Models: Singular PascalCase
Product       // Good
Products      // Bad

// Services: Noun + "Service"
ProductApiService
PaymentGatewayService

// Jobs: Descriptive verb phrase
ProcessPayment
SyncInventory

// Events: Past tense, descriptive
OrderShipped
UserRegistered

// Listeners: Descriptive action
SendShipmentNotification
UpdateInventoryStock

// Mailables: Descriptive noun
OrderConfirmation
WelcomeEmail

// Enums: Singular PascalCase
OrderStatus
PaymentMethod

// Seeders: Model + "Seeder"
UserSeeder
ProductSeeder

// Traits: Adjective or ability
HasOrders
Sortable

// Interfaces: Adjective or noun + "Interface" / "able"
Searchable
PaymentGatewayInterfaceLanguage:php

Database

// Tables: Plural snake_case
orders
order_items

// Columns: snake_case
is_active
created_at

// Migrations: Descriptive snake_case
create_orders_table
add_status_to_orders_tableLanguage:php

Routes, Views & Config

// Route names: dot-notation, snake_case
orders.store
users.show

// Blade views: kebab-case
order-details.blade.php
user-profile.blade.php

// Config keys: snake_case
config('services.api_key')Language:php

Models

Naming & Structure

Models must be singular. Organize methods in this order:

Guarded or Fillable?

By default, use $guarded. For sensitive models like User, use $fillable for stricter control:

// Default for most models
protected $guarded = ['id'];

// For sensitive models (e.g. User)
protected $fillable = ['name', 'email'];Language:php

Casts with PHP Attributes

Laravel 13 supports PHP attributes for casting. Use them instead of the $casts property:

use Illuminate\Database\Eloquent\Attributes\Cast;
use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    protected $guarded = ['id'];

    #[Cast('decimal:2')]
    protected $price;

    #[Cast('boolean')]
    protected $is_active;

    #[Cast('datetime')]
    protected $published_at;
}Language:php

Scopes with PHP Attributes

Use the ScopedBy attribute to register global scopes:

use Illuminate\Database\Eloquent\Attributes\ScopedBy;

#[ScopedBy(ActiveScope::class)]
class Product extends Model
{
    // ...
}Language:php

For local scopes, use the Scope attribute:

use Illuminate\Database\Eloquent\Attributes\Scope;
use App\Models\Scopes\ActiveScope;

#[Scope(ActiveScope::class)]
class Product extends Model
{
    // ...
}Language:php

Full Model Example

use Illuminate\Database\Eloquent\Attributes\Cast;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

#[ScopedBy(ActiveScope::class)]
class Product extends Model
{
    protected $guarded = ['id'];

    #[Cast('decimal:2')]
    protected $price;

    #[Cast('boolean')]
    protected $is_active;

    // Mutators
    protected function name(): Attribute
    {
        return Attribute::make(
            set: fn (string $value) => strtolower($value),
        );
    }

    // Relations
    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }
}Language:php

Controllers

Controllers handle the HTTP layer only. Suffix the class name with Controller (e.g. OrderController). Delegate business logic to Actions:

// Good: thin controller, logic in Action
class OrderController extends Controller
{
    public function store(StoreOrderRequest $request, CreateOrderAction $action): RedirectResponse
    {
        $order = $action->handle($request->user(), $request->validated('items'));

        return redirect()->route('orders.show', $order);
    }
}

// Bad: fat controller with business logic
class OrderController extends Controller
{
    public function store(StoreOrderRequest $request): RedirectResponse
    {
        $order = $request->user()->orders()->create([...]);
        $order->items()->createMany([...]);
        Mail::to($request->user())->send(new OrderConfirmation($order));

        return redirect()->route('orders.show', $order);
    }
}Language:php

Routing

Route Definitions

Always use class references, never strings. Import the Controllers namespace to keep routes clean:

// Good
use App\Http\Controllers;

Route::get('users', [Controllers\UserController::class, 'index']);

// Bad
Route::get('users', 'UserController@index');Language:php

Routes for Development or Testing

Development routes must not be placed in routes/web.php or routes/api.php. Create a separate routes/testing.php and register it only in local or testing environments:

// bootstrap/app.php

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
        then: function () {
            if (App::environment('local', 'testing')) {
                Route::prefix('testing')
                    ->name('testing.')
                    ->group(base_path('routes/testing.php'));
            }
        },
    )Language:php

Request Validation

Define validation rules as arrays, not pipe notation. This makes it easier to use custom rules:

// Good
public function rules(): array
{
    return [
        'number' => [
            'numeric',
            Rule::in(['1', '3', '5']),
        ],
    ];
}

// Bad
public function rules(): array
{
    return [
        'number' => 'numeric|max:255',
    ];
}Language:php

Configuration & Environment

  • Never commit secrets to version control, not even in .env.example.

  • Only use env() inside config/ files. Everywhere else, use config() to access values:

// Good: in config/services.php
'api_key' => env('API_KEY'),

// Good: everywhere else
$key = config('services.myservice.api_key');

// Bad: env() outside config/
$key = env('API_KEY');Language:php
  • No trailing slashes in URL values.

Component Design

CRUD: Separate Components per Action

Do not combine create and edit into one component. This violates the single responsibility principle and leads to messy conditionals:

// Bad: mixed concerns
if (action == 'create') { /* ... */ }
if (action == 'edit') { /* ... */ }Language:javascript

Instead, create separate components (e.g. create.vue, edit.vue) and share common parts like form fields via reusable sub-components.

Frontend

Vue / Nuxt Components

All components must have a name attribute:

export default {
  name: 'DocumentEdit',
}Language:javascript

React

Mandatory read for every developer working with React: You Might Not Need an Effect. Most misuse of useEffect leads to unnecessary complexity, bugs, and performance issues. Understand when state derivation, event handlers, or useMemo are the better choice.

Tailwind CSS

Custom classes go first in a class attribute:

<div class="my-custom-class mx-auto"></div>Language:html

Working with Money

protected function price(): Attribute
{
    return Attribute::make(
        get: fn (int $value) => new Money($value, new Currency('EUR')),
    );
}Language:php

Statamic

When to Use a Tag vs. a View Component

Statamic Tags and Laravel View Components can both render dynamic content in templates, but they serve different purposes. Choosing the right one keeps your architecture clean.

Use a View Component when:

  • You are loading external or custom data (APIs, your own database tables, non-Statamic sources).

  • The component is presentation-focused and just needs data passed in via props.

  • You want testability -- View Components are trivially unit-testable.

  • You want reusability outside Statamic -- the same component works in any Laravel context (Livewire, standalone Blade, etc.).

  • You are using Blade templates throughout.

View Components combined with the Action pattern give you a clean layered architecture: Service (HTTP/data access) -> Action (business logic) -> View Component (data preparation) -> Blade template (presentation only). See our guide on integrating custom data sources in Statamic for a full example.

Use a Tag when:

  • You need to interact with Statamic's content system -- accessing entries, collections, globals, taxonomies, or augmented values.

  • You need access to template context ($this->context) -- e.g. reading variables set higher up in the template.

  • You are building something for Antlers templates (Tags work in both Antlers and Blade; View Components only work in Blade).

  • You are building a reusable Statamic addon or package that other Statamic users will install.

  • You need tag pair behavior with no_results support, wildcard methods, or other Statamic-specific template features.

Rule of thumb: If the data comes from Statamic, use a Tag. If the data comes from anywhere else, use a View Component with an Action.