Blade, PHP & JavaScript Coding Standards
Essential patterns, naming conventions, and structural rules enforced across the Rural King codebase. Server-driven architecture — let Turbo handle the UI, let the service layer handle the logic.
Non-negotiable requirements
Core Requirements
Six non-negotiable rules. Every file in the codebase must comply — code review will catch violations before merge.
MVC + Services
Controllers call services. Services call repositories. Repositories return DTOs. No SQL in controllers. No business logic in views.
Icon Components
Always <x-icons.name>. Never inline SVG in Blade. One component file per icon under components/icons/.
DaisyUI Semantic
Always bg-base-100, not bg-white. Always text-base-content, not text-gray-900. Never hardcode colors.
safe() on SQL
Every value interpolated into a raw SQL string must pass through $this->db->safe(). No exceptions.
Route Separation
web.php returns views/redirects only. api.php returns JSON only. Never mix responsibilities.
Turbo Over AJAX
Use Turbo Drive, Frames, and Streams before reaching for custom fetch. Stimulus is for behaviour, not navigation.
Philosophy: the server is the source of truth. HTML is rendered server-side and delivered to the browser. Turbo applies it. Stimulus adds targeted behaviour. The less custom JavaScript, the better.
Naming Conventions
Consistent naming is enforced across the entire codebase. The suffix tells you the role; the case tells you the layer.
PHP class naming
| Type | Convention | Example |
|---|---|---|
| Controller | PascalCase + Controller | ListsController |
| Service | PascalCase + Service | ListsService |
| Repository | PascalCase + Repository | ListsRepository |
| Interface | PascalCase + Interface | ListsRepositoryInterface |
| DTO | PascalCase + Dto | ListDto, ProductDto |
| Model | PascalCase (singular) | User, Announcement |
| Middleware | PascalCase + Middleware (optional) | EnsureDeviceAccess |
PHP member naming
class ListsService
{
// Properties: camelCase
private ListsRepositoryInterface $listsRepository;
// Methods: camelCase, descriptive verbs
public function getLists(): array { ... }
public function createList(): bool { ... }
public function deleteList(int $id): bool {}
// Constants: UPPER_SNAKE_CASE
const DEFAULT_PAGE_SIZE = 20;
const MAX_ITEMS_PER_LIST = 500;
}
File and directory naming
| Location | Convention | Example |
|---|---|---|
| PHP classes | PascalCase.php | ListsService.php |
| Blade views | kebab-case.blade.php | list-items-frame.blade.php |
| Blade components | kebab-case.blade.php | page-header.blade.php |
| JS controllers | snake_case_controller.js | lists_show_controller.js |
| Feature folders | PascalCase/ | Controllers/Lists/ |
| View folders | kebab-case/ | views/lists/ |
Route naming
// Route names: kebab-case segments
// joined by dots
Route::get('/lists', ...)
->name('lists.index');
Route::get('/lists/{id}/edit', ...)
->name('lists.edit');
Route::post('/lists/{id}/add-item', ...)
->name('lists.add-item');
// Usage in Blade: route() helper
<a href="{{ route('lists.index') }}">
All Lists
</a>
Icon Component Architecture
Every icon in the application must be a Blade component. Inline SVGs in templates are forbidden — they are unreadable, untestable, and impossible to update globally.
Icon component structure
@props(['class' => 'w-5 h-5'])
<svg {{ $attributes->merge(['class' => $class]) }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
Creating a new icon
- Create
resources/views/components/icons/icon-name.blade.php - Start with
@props(['class' => 'w-5 h-5']) - Use
$attributes->merge(['class' => $class])on the<svg>tag - Always use
fill="none" stroke="currentColor" viewBox="0 0 24 24" - Always use
stroke-linecap="round" stroke-linejoin="round" stroke-width="2" - Add the new icon name to the
.cursorrulesavailable icons list
Correct vs incorrect usage
<!-- Simple usage -->
<x-icons.search class="w-5 h-5 text-gray-400" />
<!-- With additional attributes -->
<x-icons.spinner
class="w-6 h-6 animate-spin text-primary"
aria-label="Loading…"
/>
<!-- Size override from default -->
<x-icons.trash class="w-4 h-4 text-error" />
<x-icons.edit class="w-4 h-4 text-base-content" />
<!-- In a button -->
<button class="btn btn-primary">
<x-icons.package class="w-5 h-5" />
Add Product
</button>
<!-- This is forbidden, always -->
<svg class="w-5 h-5" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6…" />
</svg>
The $attributes->merge pattern allows callers to pass any HTML attribute (aria-label, id, data-*) and it will be merged with the component's default classes.
Database Access & Security
The legacy database layer uses raw SQL via mysqli. This is powerful but requires strict discipline. Every value from user input, request parameters, or external systems must be escaped before interpolation into SQL.
Always use safe() before SQL interpolation
public function getList(
int $id,
int $locationId
): ?ListDto {
// Escape scalars
$safeId = $this->db->safe($id);
$safeLoc = $this->db->safe($locationId);
$query = "SELECT id, name, type
FROM api.sku_list
WHERE id = $safeId
AND location_id = $safeLoc
AND active = 1";
return $this->mapRowToDto(
$this->db->query($query)->fetch_assoc()
);
}
// Escape arrays (e.g. IN clauses)
$safeSkus = $this->db->safe($skuArray);
$inClause = implode("','", $safeSkus);
$query = "SELECT * FROM products
WHERE sku IN ('$inClause')";
// SQL injection vulnerability!
$query = "SELECT * FROM api.sku_list
WHERE id = {$request->id}";
// Also wrong — even integers from user input
// must be escaped or cast before interpolation
$id = $request->input('id');
$query = "SELECT * FROM lists WHERE id = $id";
Other security requirements
- CSRF protection — all POST/PUT/DELETE forms must include
- Input validation — use
$request->validate()in every controller before touching data - Auth middleware — all app routes must be inside the
authmiddleware group - No raw queries in controllers — SQL belongs in repositories, never in controllers or services
public function store(Request $request)
{
// Always validate BEFORE any processing
$validated = $request->validate([
'name' => 'required|string|max:255',
'type_id' => 'required|integer|min:1',
'sku' => 'required|string|regex:/^\d+$/',
]);
$this->listsService->create($validated);
return redirect()->route('lists.index');
}
Route Organization
The two route files have strict, non-overlapping responsibilities. A route in the wrong file is an architectural violation, not a style preference.
web.php — views and redirects ONLY
// ✅ Returns a view
Route::get('/lists',
[ListsController::class, 'index']);
// ✅ Redirects
Route::get('/', fn () =>
redirect('/dashboard'));
// ✅ POST that redirects after processing
Route::post('/lists',
[ListsController::class, 'store']);
// ✅ DELETE that redirects
Route::delete('/lists/{id}',
[ListsController::class, 'destroy']);
// ❌ Returns JSON — use api.php instead
Route::get('/lists/{id}/data',
fn ($id) => response()->json(
$listsService->getList($id)
)
);
api.php — JSON responses ONLY
Route::middleware(['web', 'auth'])
->group(function () {
// ✅ Returns JSON data
Route::get('/products/search',
SearchProductsController::class);
// ✅ Returns JSON for AJAX/native
Route::get('/lists/{id}/csv',
GetListCsvController::class);
// ✅ Invokable controller, returns JSON
Route::get('/user',
GetUserController::class);
});
Why this matters
- Turbo Drive expects HTML from
web.phproutes and knows how to handle redirects - Mobile apps consume
api.phproutes for JSON data, separate from the web navigation flow - Clear separation makes it immediately obvious what each endpoint does from its location alone
- Middleware groups can be applied consistently per file without per-route exceptions
Controller Patterns
Controllers are thin adapters between HTTP and the application. They validate, delegate, and respond. All business logic lives in the service layer.
Web controller — resource pattern
class ListsController extends Controller
{
public function __construct(
private ListsService $listsService
) {}
// index, create, store, show,
// edit, update, destroy — standard methods
public function store(
Request $request
): RedirectResponse {
// 1. Validate
$data = $request->validate([...]);
// 2. Delegate to service
$this->listsService->create($data);
// 3. Redirect with flash message
return redirect()
->route('lists.index')
->with('success', 'List created.');
}
public function destroy(
int $id,
Request $request
): RedirectResponse {
$this->listsService->delete($id);
return redirect()
->route('lists.index')
->with('success', 'List deleted.');
}
}
API controller — single action, invokable
// One file, one responsibility.
// File: Api/Lists/GetListCsvController.php
class GetListCsvController extends Controller
{
public function __construct(
private ExportListsService $exportService
) {}
// __invoke = the only method
public function __invoke(
Request $request
): JsonResponse {
$request->validate([
'listId' => 'required|integer',
]);
$csv = $this->exportService
->generateCsvContent(
$request->integer('listId')
);
return response()->json(['csv' => $csv]);
}
}
What controllers must NOT do
- Run database queries directly (
new DBConnect(), Eloquent queries) - Contain business logic (calculations, validation rules beyond format)
- Call repositories directly — always go through the service layer
- Return both views and JSON from the same controller
Service & Repository Responsibilities
The service and repository layers each have a single, clear responsibility. Mixing them — putting SQL in services or business logic in repositories — creates a codebase that is impossible to test or swap.
Service — what it DOES
- Orchestrates one or more repository calls to fulfil a use case
- Applies caching (
Cache::remember) when appropriate - Transforms or combines DTOs when needed
- Depends only on repository interfaces, never concrete classes
Service — what it must NOT do
- Instantiate
DBConnector run SQL - Know whether data comes from MySQL or an HTTP API
- Access
$requestor any HTTP-layer object - Return raw arrays from the database — always return DTOs
Repository — what it DOES
- Implements the typed repository interface
- Constructs and executes SQL queries using
DBConnect - Escapes all inputs with
$this->db->safe() - Maps result rows to DTOs using
DTO::fromArray()or a private mapping method
Repository — what it must NOT do
- Apply business rules or conditional logic based on domain concepts
- Call other repositories (that is the service's job)
- Return raw
mysqli_resultor associative arrays — always map to DTOs - Cache data — caching belongs in the service layer
The contract: if you swap ListsRepository for a mock or a new implementation, the ListsService tests must not need to change. If they do, the service is too tightly coupled to the concrete repository.
Blade Component Usage
Every reusable UI piece is a Blade component. Use the existing components before writing custom HTML. Components encapsulate structure, styling, and dark mode behaviour — callers should not replicate that logic.
Layout and structural components
<!-- Page layout -->
<x-layouts.app :title="'Lists'">
<!-- Sticky page header with optional icon -->
<x-common.page-header title="My Lists">
<x-slot:icon>
<x-icons.clipboard class="w-5 h-5" />
</x-slot:icon>
</x-common.page-header>
<!-- Card container -->
<x-common.card>
<!-- Typography -->
<x-text.heading>Active Lists</x-text.heading>
<x-text.subheading>
Your store's current lists.
</x-text.subheading>
<!-- Status badge -->
<x-common.badge
color="success"
>Active</x-common.badge>
</x-common.card>
<!-- Top-bar action menu (native bridge) -->
<x-common.top-bar-menu :items="[
['label' => 'Export', 'action' => 'export'],
['label' => 'Delete', 'action' => 'delete'],
]" />
</x-layouts.app>
Form components
<form method="POST" action="{{ route('lists.store') }}">
@csrf
<x-form.label for="name">
List Name
</x-form.label>
<x-form.text-input
id="name"
name="name"
:value="old('name')"
placeholder="Enter list name…"
required
/>
<x-form.error field="name" />
<x-form.label for="type_id">Type</x-form.label>
<x-form.select name="type_id">
@foreach($types as $type)
<option value="{{ $type->id }}">
{{ $type->name }}
</option>
@endforeach
</x-form.select>
<x-form.button.primary type="submit">
Create List
</x-form.button.primary>
<x-form.button.outline type="button">
Cancel
</x-form.button.outline>
</form>
Form components automatically apply DaisyUI classes (input input-bordered, select select-bordered), display validation errors, and adapt to dark mode. Never use raw <input> or <select> in Blade pages.
Dark Mode — DaisyUI Semantic Tokens
Dark mode is automatic and system-driven. There is no manual theme toggle. DaisyUI reads prefers-color-scheme from the OS and applies the correct theme. Your only job is to use semantic tokens.
Always use semantic tokens
| Use this (semantic) | Never this (hardcoded) |
|---|---|
bg-base-100 | bg-white, bg-gray-950 |
bg-base-200 | bg-gray-50, bg-gray-900 |
bg-base-300 | bg-gray-100, bg-gray-800 |
text-base-content | text-gray-900, text-white |
text-base-content/70 | text-gray-500, text-gray-400 |
border-base-content/10 | border-gray-200, border-gray-700 |
bg-primary | bg-red-600, #bf0004 |
text-primary | text-red-600 |
How it works: DaisyUI maps each theme to a set of CSS custom properties. bg-base-100 maps to a light background in the light theme and a dark background in the dark theme — automatically, with no JavaScript.
Correct usage examples
<div class="bg-base-100
border border-base-content/10
rounded-lg p-4">
<h2 class="text-base-content font-semibold">
Product Details
</h2>
<p class="text-base-content/70 text-sm">
SKU: {{ $product->sku }}
</p>
<button class="btn btn-primary">
Add to List
</button>
</div>
<!-- These break in dark mode -->
<div class="bg-white border-gray-200">
<h2 class="text-gray-900">Product</h2>
<p class="text-gray-500">SKU: 12345</p>
<button class="bg-red-600 text-white">
Add
</button>
</div>
When dark: prefix is acceptable
Only use dark: when DaisyUI semantic tokens do not cover the specific design need (e.g. a custom background gradient or a third-party component override). Always prefer semantic tokens first.
Turbo Conventions
Turbo is the preferred solution for any UI update. Before writing custom JavaScript, ask: can Turbo Drive, a Turbo Frame, or a Turbo Stream handle this? In most cases, the answer is yes.
Turbo Drive — navigation
<!-- For forms where the response is a redirect
or a full-page HTML swap -->
<form
method="POST"
action="{{ route('lists.store') }}"
data-turbo="true"
data-controller="submission"
data-action="
turbo:submit-start->submission#disable
turbo:submit-end->submission#enable
"
>
@csrf
...
</form>
<!-- For pages with sensitive state,
prevent Turbo from caching -->
@push('head')
<meta name="turbo-cache-control"
content="no-cache">
@endpush
Turbo Frames — partial updates
<!-- Target a frame from a link -->
<a href="{{ route('lists.items', $list) }}"
data-turbo-frame="list-items"
>
Refresh Items
</a>
<!-- Escape the frame for full-page nav -->
<a href="{{ route('lists.edit', $list) }}"
data-turbo-frame="_top"
>
Edit List
</a>
Turbo Streams — precise mutations
<!-- In the controller (for stream responses) -->
return response()
->view('lists.partials.turbo-stream-update',
compact('list'))
->header('Content-Type',
'text/vnd.turbo-stream.html');
<!-- In the stream partial -->
<turbo-stream action="replace" target="list-items">
<template>
@include('lists.partials.list-items-frame')
</template>
</turbo-stream>
<turbo-stream action="append" target="notifications">
<template>
@include('partials.notice', ['message' => $msg])
</template>
</turbo-stream>
Ephemeral UI
<!-- data-turbo-temporary means Turbo removes
this element from page snapshots/cache -->
<div
data-turbo-temporary
data-controller="bridge--toast flash"
>
{{ session('success') }}
</div>
Decision rule: if an update involves replacing or modifying an element with server-rendered HTML, use Turbo Streams. If it involves navigating to or partially refreshing a section of a page, use Turbo Frames. Turbo Drive handles everything else automatically.
Stimulus Conventions
Stimulus controllers add targeted behaviour to existing server-rendered HTML. They are not components — they do not own or render HTML. Keep controllers small, focused, and named after the feature or element they enhance.
Controller naming and placement
// resources/js/controllers/
// snake_case_controller.js → data-controller="kebab-case"
lists_show_controller.js
→ data-controller="lists-show"
sidebar_controller.js
→ data-controller="sidebar"
flash_controller.js
→ data-controller="flash"
// Bridge controllers in a subfolder
bridge/toast_controller.js
→ data-controller="bridge--toast"
bridge/dropdown_menu_controller.js
→ data-controller="bridge--dropdown-menu"
Stimulus anatomy
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
// Declare targets, values, classes at top
static targets = ["button", "spinner"];
static values = { url: String };
connect() { /* runs when DOM connects */ }
disconnect() { /* runs when removed */ }
// Methods named after what they do
submit() { ... }
reset() { ... }
}
Wiring in Blade
<div
data-controller="lists-show"
data-lists-show-edit-url-value="{{ route('lists.edit', $list) }}"
data-lists-show-delete-url-value="{{ route('lists.destroy', $list) }}"
>
<!-- data-action: "event->controller#method" -->
<button
data-action="click->lists-show#edit"
>Edit</button>
<button
data-action="click->lists-show#delete"
>Delete</button>
<!-- data-*-target for DOM references -->
<span data-lists-show-target="title">
{{ $list->name }}
</span>
</div>
Navigation — always use Turbo.visit
// ✅ Preserves Turbo history and animations
window.Turbo.visit(this.editUrlValue);
window.Turbo.visit(url, { action: "replace" });
// ❌ Bypasses Turbo entirely
window.location.href = this.editUrlValue;
window.location.assign(url);
Critical DON'Ts
These patterns are architectural violations or security risks. A PR containing any of them will not be merged until corrected.
Blade & frontend
- No inline SVGs — always use
<x-icons.name>components - No hardcoded colors — always use DaisyUI semantic tokens
- No raw HTML inputs — always use
<x-form.*>components - No custom CSS for colors or layouts that Tailwind/DaisyUI cover
- No
window.location.hreffor navigation — useTurbo.visit() - No custom AJAX (
fetch/XMLHttpRequest) when Turbo Drive/Frames/Streams can handle the update
PHP & architecture
- No SQL in controllers — all queries belong in repositories
- No direct repository calls from controllers — always go through a service
- No unescaped SQL values — always call
$this->db->safe()first - No JSON in web.php routes — move to
api.php - No view rendering in api.php routes — move to
web.php
// ❌ Inline SVG in Blade
<svg fill="none" …><path d="M21 21…" /></svg>
// ❌ Hardcoded color classes
<div class="bg-white text-gray-900">…</div>
// ❌ SQL in a controller
public function index(Request $request) {
$db = new DBConnect();
$result = $db->query("SELECT * FROM lists");
return view('lists.index',
compact('result'));
}
// ❌ No safe() on interpolated SQL
$query = "SELECT * FROM lists
WHERE id = {$request->id}";
// ❌ Bypassing the service layer
class ListsController {
public function index() {
$repo = new ListsRepository();
$lists = $repo->getLists();
return view('lists.index',
compact('lists'));
}
}
// ❌ JSON response in web.php
Route::get('/lists/data',
fn () => response()->json($lists)
); // belongs in api.php
// ❌ window.location in Stimulus
navigate() {
window.location.href = this.urlValue;
}
At-a-Glance Reference
Always do
- Use
<x-icons.name>for every icon - Use DaisyUI semantic classes (
bg-base-100,text-base-content) - Use
$this->db->safe()before every SQL interpolation - Use
on every POST/PUT/DELETE form - Validate with
$request->validate()before processing - Route to
web.phpfor views,api.phpfor JSON - Inject services into controllers, interfaces into services
- Return DTOs from repositories (
fromArray()) - Use
data-turbo="true"on forms for Turbo Drive - Use
data-turbo-temporaryon toasts and flash messages - Use
Turbo.visit()for programmatic navigation in Stimulus - Return
text/vnd.turbo-stream.htmlfor stream responses - Name JS controllers
snake_case_controller.js
Never do
- Inline SVG in Blade templates
- Hardcoded color classes (
bg-white,text-gray-900) - SQL without
$this->db->safe() - SQL in controllers or services
- Call repositories directly from controllers
- Return raw arrays from repositories — always DTOs
- JSON responses in
web.php - View responses in
api.php - Custom
fetch/XMLHttpRequestwhen Turbo can handle it window.location.hrefin Stimulus controllers- Raw
<input>or<select>tags — use<x-form.*> - Business logic in Blade templates or views
- Bridge controllers (
bridge/) for non-native behaviour
Full architecture deep-dive in the Laravel, Hotwire & Turbo Architecture document. Reference that course whenever these rules need more context.