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/pint

String Interpolation

Always use interpolation over concatenation:

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

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; } }

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.php

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); } }

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

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

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 collection

Model Properties

Model properties use snake_case matching their column names:

// Good $model->updated_at // Bad $model->updatedAt

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 PaymentGatewayInterface

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_table

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')

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'];

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; }

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 { // ... }

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 { // ... }

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); } }

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); } }

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');

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')); } }, )

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', ]; }

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');
  • 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') { /* ... */ }

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', }

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:

Working with Money

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

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.