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.gitCreate a branch for your feature, always referencing the ticket number:
git checkout -b feature/TN-1337_feature_nameCreate 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
Background: Martin Fowler's take on money in software
Store money as minor values (cents) in integer columns.
Use Eloquent Accessors to cast to moneyphp/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.