Laravel, Hotwire & Turbo Architecture
A full-stack architecture where the server always drives the UI: Laravel handles business logic, Turbo handles navigation and updates, and Stimulus adds minimal JavaScript behaviour.
Full request flow — from browser to database and back
Project Layout
Every directory has a single, well-defined responsibility. The project is organised by feature within each layer, not by technical type across features.
Backend (app/)
app/
Http/
Controllers/
Api/ ← JSON endpoints (invokable)
Dashboard/ ← Feature controllers
Lists/
Products/
Settings/
Tasks/
Middleware/ ← Device access, store, native
Services/ ← Business logic orchestration
DataSource/
Legacy/
Concrete/ ← mysqli repositories
DTOs/ ← Readonly data shapes
Interfaces/ ← Repository contracts
Atk2/ ← HTTP API repositories
Models/ ← Eloquent (auth + a few tables)
Providers/ ← Service + DataSource binding
Frontend (resources/)
resources/
views/
components/
layouts/ ← app, auth, desktop-sidebar
common/ ← page-header, card, badge…
common/icons/ ← SVG icon components
form/ ← inputs, buttons, toggles
text/ ← heading, subheading
partials/ ← head, notice, notifications
<feature>/ ← blade pages per feature
js/
controllers/ ← Stimulus controllers
libs/ ← turbo, stimulus boot
elements/ ← turbo-echo-stream-tag
helpers/ ← shared utilities
css/
app.css ← Tailwind entry point
routes/
web.php ← View/redirect routes
api.php ← JSON/AJAX routes
importmap.php ← JS module pins
Rule: feature folders group related files. All Lists-related controllers, views, and services live under their own Lists/ folder — not scattered by file type.
Route Architecture
Routes are split into two files with strict responsibilities. web.php only returns views or redirects. api.php only returns JSON. Never mix them.
web.php — views and redirects only
// Public: redirects, legal pages
Route::get('/', fn () => redirect('/dashboard'));
// Authenticated app routes
Route::middleware(['auth', 'store.selected'])
->group(function () {
Route::get('/dashboard',
[DashboardController::class, 'index']);
Route::get('/lists',
[ListsController::class, 'index']);
Route::post('/lists',
[ListsController::class, 'store']);
Route::get('/lists/{id}/edit',
[ListsController::class, 'edit']);
});
api.php — JSON responses only
// API routes use web middleware for
// session auth (CSRF-friendly)
Route::middleware(['web', 'auth'])
->group(function () {
// Invokable single-action controllers
Route::get('/user',
GetUserController::class);
Route::get('/lists/{id}/csv',
GetListCsvController::class);
Route::get('/products/search',
SearchProductsController::class);
});
Key middleware aliases
| Alias | Purpose |
|---|---|
store.selected | Requires a store to be selected for the session |
high.access | Requires elevated access level |
impersonating | Requires active impersonation session |
EnsureDeviceAccess is applied globally on both web and api middleware groups. It gates access via the rk_lens_access header or a User-Agent token, ensuring only authorized devices (native apps or approved browsers) reach any route.
Controllers
Controllers are the HTTP entry point. They validate requests, call services, and return the appropriate response — never containing business logic or database queries.
Web controllers — return views
class ListsController extends Controller
{
public function __construct(
private ListsService $listsService,
private ProductService $productService
) {}
public function index(Request $request): View
{
$storeId = $request->user()->current_store_id;
$lists = $this->listsService->getLists([
'locations' => [$storeId]
], true);
return view('lists.index',
compact('lists'));
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'type_id' => 'required|integer',
]);
$this->listsService->create($validated);
return redirect()->route('lists.index')
->with('success', 'List created.');
}
}
API controllers — single-action, return JSON
class GetListCsvController extends Controller
{
public function __construct(
private ExportListsService $exportService
) {}
public function __invoke(
Request $request
): JsonResponse {
$request->validate([
'listId' => 'required|integer',
]);
$csv = $this->exportService
->generateCsvContent(
$request->integer('listId')
);
return response()->json([
'csv' => $csv,
]);
}
}
Pattern: API controllers are always invokable (single __invoke method). One file, one responsibility. Web controllers may have multiple methods (index, show, store, update, destroy) following the standard resource convention.
Service Layer
Services contain application use-case logic. They orchestrate one or more repositories, apply caching when needed, and return domain data. They depend only on repository interfaces, never on concrete implementations.
ListsService — thin orchestration
class ListsService
{
public function __construct(
// Depends on the INTERFACE, not the
// concrete repository class
private ListsRepositoryInterface $listsRepository
) {}
public function getLists(
array $filters = [],
bool $includeItems = false
): array {
return $this->listsRepository
->getLists($filters, $includeItems);
}
public function getList(
int $id,
int $userLocation
): ?ListDto {
return $this->listsRepository
->getList($id, $userLocation);
}
public function create(array $data): bool
{
return $this->listsRepository
->create($data);
}
}
ProductService — with caching layer
class ProductService
{
public function __construct(
private ProductsInterface $productsRepository
) {}
public function getProductByCode(
string $productCode,
int $storeNumber,
bool $getSales = false
): ?ProductDto {
$cacheKey = "product:{$storeNumber}:"
. "{$productCode}:"
. ($getSales ? '1' : '0');
// Services may add caching — repositories
// should not know about the cache layer
return Cache::remember(
$cacheKey,
30, // seconds
fn () => $this->searchProducts(
$productCode,
[$storeNumber],
$getSales
)[0] ?? null
);
}
}
Key rule: services never touch the database directly. Controllers never call repositories directly. The service is the only bridge between the HTTP layer and the data layer.
Repository Pattern
Repositories are the only place where data is fetched or persisted. They implement a typed interface so the service layer remains decoupled from any specific data source — SQL, HTTP API, or otherwise.
Interface — the contract
interface ListsRepositoryInterface
{
/** @return ListDto[] */
public function getLists(
array $filters = [],
bool $includeItems = false
): array;
public function getList(
int $id,
int $userLocation,
bool $includeItems = true
): ?ListDto;
public function create(array $data): bool;
public function update(
int $id,
array $data
): bool;
public function delete(int $id): bool;
}
Two data source types
Legacy (mysqli)
Uses DBConnect with raw SQL against the rk MySQL database. All rural king business data: lists, products, prices, printers, stores, auth.
ATK2 (HTTP)
HTTP client repositories for the external ATK2 API. Used for tasks, posts, playbooks, SOPs, and reference documents.
Repositories in each layer
| Layer | Role |
|---|---|
| Interface | Declares the contract. Defined in DataSource/Legacy/Interfaces/. |
| Service | Depends on the interface. Calls methods, receives DTOs. |
| Concrete | Implements the interface. Lives in DataSource/Legacy/Concrete/. Contains all SQL. |
Binding: DataSourceServiceProvider maps each interface to its concrete class. The service only ever sees the interface — it cannot know whether the data comes from MySQL or an HTTP API.
DBConnect & Legacy Data Access
DBConnect is a thin mysqli wrapper that reads credentials from the rk Laravel database connection. It provides raw SQL execution and a mandatory escaping method.
How DBConnect works
class DBConnect
{
private mysqli $connection;
public function __construct(
?string $database = null
) {
// null = connect without selecting a DB
// used for cross-schema queries
$this->connection = new mysqli(
config('database.connections.rk.host'),
config('database.connections.rk.username'),
config('database.connections.rk.password'),
$database
);
}
public function query(string $sql): mysqli_result
{
$result = $this->connection->query($sql);
if ($result === false) {
throw new \RuntimeException(
$this->connection->error
);
}
return $result;
}
// ALWAYS use this before interpolating
// any user-supplied value into SQL
public function safe(
string|array $input
): string|array {
if (is_array($input)) {
return array_map(
fn($v) => $this->connection
->real_escape_string((string)$v),
$input
);
}
return $this->connection
->real_escape_string((string)$input);
}
}
Repository pattern with DBConnect
class ListsRepository
implements ListsRepositoryInterface
{
private DBConnect $db;
public function __construct()
{
// Default: connects to the rk database
$this->db = new DBConnect();
}
public function getList(
int $id,
int $userLocation
): ?ListDto {
// Always escape before interpolating
$safeId = $this->db->safe($id);
$safeLoc = $this->db->safe($userLocation);
$query = "SELECT
sl.id,
sl.name,
sl.type,
sl.location_id
FROM api.sku_list sl
WHERE sl.id = $safeId
AND sl.location_id = $safeLoc
AND sl.active = 1";
$result = $this->db->query($query);
if ($result->num_rows === 0) {
return null;
}
return $this->mapRowToDto(
$result->fetch_assoc()
);
}
}
Cross-database queries: some repositories use new DBConnect(null) (no default database) to query multiple schemas like api.sku_list and ldap.associate_listing in a single CTE.
Data Transfer Objects (DTOs)
DTOs are readonly data shapes that flow from repositories to services to controllers. They replace raw associative arrays with typed, predictable structures and provide a stable contract between layers.
Product DTO — fromArray + toArray
final readonly class ProductDto
{
public function __construct(
public string $sku,
public string $description,
public string $location,
public float $price,
public int $quantity,
public bool $isVerified,
) {}
// Static constructor maps raw DB row
// column names to typed properties
public static function fromArray(
array $row
): self {
return new self(
sku: $row['SKU'],
description:$row['DESCRIPTION'],
location: $row['LOCATION'],
price: (float) $row['PRICE'],
quantity: (int) $row['QTY'],
isVerified: (bool) $row['VERIFIED'],
);
}
// Serialization for views / JSON responses
public function toArray(): array
{
return [
'sku' => $this->sku,
'description' => $this->description,
'price' => $this->price,
'quantity' => $this->quantity,
'isVerified' => $this->isVerified,
];
}
}
List DTO — nested DTOs
class ListDto
{
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly ListTypeDto $type,
public readonly int $locationId,
// Nested DTO collection
/** @var ListItemDto[] */
public readonly array $items = [],
) {}
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'type' => $this->type->toArray(),
'locationId' => $this->locationId,
'items' => array_map(
fn($i) => $i->toArray(),
$this->items
),
];
}
}
DTO rules
- Always
readonly— DTOs are immutable once created - Use
fromArray()to construct from database rows - Use
toArray()to serialize for views or JSON - DTOs may nest other DTOs (e.g.
ListDtocontainsListItemDto[]) - No business logic inside DTOs — they are pure data carriers
Dependency Injection & Service Provider
The DataSourceServiceProvider is the only place where interface-to-implementation bindings are declared. Laravel's container resolves these at runtime, keeping the service layer decoupled from any specific data source.
DataSourceServiceProvider
class DataSourceServiceProvider
extends ServiceProvider
{
public function register(): void
{
// Legacy MySQL repositories
$this->app->bind(
AuthenticationRepositoryInterface::class,
AuthenticationRepository::class
);
$this->app->bind(
ListsRepositoryInterface::class,
ListsRepository::class
);
$this->app->bind(
ProductsInterface::class,
ProductsRepository::class
);
$this->app->bind(
PrinterRepositoryInterface::class,
PrinterRepository::class
);
// ATK2 HTTP API repositories
$this->app->bind(
TasksRepositoryInterface::class,
Atk2TasksRepository::class
);
$this->app->bind(
PostsRepositoryInterface::class,
Atk2PostsRepository::class
);
}
}
How bindings flow through the stack
ListsService in constructorListsRepositoryInterfaceListsRepositoryDBConnect, returns ListDtoTo swap a data source (e.g. move from mysqli to Eloquent), change only the binding in the provider and write a new concrete class implementing the interface. No service, controller, or test needs to change.
Turbo Drive — Navigation Without Page Reloads
Turbo Drive is the first layer of Hotwire. It automatically intercepts link clicks and form submissions, fetches the target page via `fetch`, and swaps only the <body> — giving the feel of a SPA with zero custom JavaScript.
How it boots
// resources/js/app.js
import "libs"; // boots Turbo + Stimulus
import "elements/turbo-echo-stream-tag";
import "helpers";
// resources/js/libs/index.js
import "libs/turbo"; // imports @hotwired/turbo
import "controllers"; // registers Stimulus
// routes/importmap.php — browser module pins
pin "@hotwired/turbo",
to: "js/vendor/@hotwired--turbo.js";
Asset tracking
<link rel="stylesheet"
href="{{ mix('dist/css/app.css') }}"
data-turbo-track="reload">
<x-importmap::tags />
Form opt-in
<!-- Without data-turbo="true", Laravel forms
submitted with POST are handled normally.
Adding it lets Turbo intercept the submit
and swap the response body. -->
<form
id="create-list-form"
method="POST"
action="{{ route('lists.store') }}"
data-turbo="true"
>
@csrf
...
</form>
Turbo events for UX
document.addEventListener(
'turbo:submit-start',
() => btn.disabled = true
);
document.addEventListener(
'turbo:submit-end',
() => btn.disabled = false
);
// Runs on first load AND Turbo navigations:
document.addEventListener('turbo:load', init);
// Reset form when leaving the page:
document.addEventListener(
'turbo:before-visit', resetForm
);
Cache control: pages with dynamic state (e.g. product scan, settings) add <meta name="turbo-cache-control" content="no-cache"> in a @push('head') block to prevent Turbo from serving stale cached snapshots.
Turbo Frames — Scoped Partial Updates
Turbo Frames let you update a portion of the page without a full reload. A <turbo-frame> intercepts navigation from links and forms within it, fetching the response and swapping only its own content.
Frame definition
<!-- The id must be unique on the page.
data-turbo-action="advance" means frame
navigations update the browser history. -->
<turbo-frame
id="list-items"
data-turbo-action="advance"
>
<!-- Links and forms inside this frame will
target this frame by default -->
@foreach($list->items as $item)
<div class="list-item">
<span>{{ $item->sku }}</span>
<!-- Clicking edit leaves the frame -->
<a
href="{{ route('lists.edit-item', $item) }}"
data-turbo-frame="_top"
>Edit</a>
</div>
@endforeach
<!-- This form targets BOTH frames at once -->
<form
data-turbo-frame="list-items list-info"
action="{{ route('lists.add-item') }}"
method="POST"
data-turbo="true"
>
@csrf
...
</form>
</turbo-frame>
Frame attributes reference
| Attribute | Effect |
|---|---|
id="list-items" | Identifies the frame. Server responses must contain a matching <turbo-frame id="list-items">. |
data-turbo-action="advance" | Frame navigations push a new browser history entry (like a full page nav). |
data-turbo-frame="_top" | On a link/form, escapes the frame and navigates the full page. |
data-turbo-frame="a b" | Space-separated list: updates both frame a and frame b from a single response. |
How the server responds
When a frame makes a request, the server renders the full page. Turbo extracts only the <turbo-frame id="list-items"> from the response and swaps it in — the rest is discarded.
Rule: partials rendered inside a frame must also include a matching <turbo-frame> wrapper. If the response has no matching frame, Turbo will silently fail.
Turbo Streams — Server-Sent DOM Mutations
Turbo Streams let the server send precise DOM operations in response to a form submission or broadcast event. Instead of replacing a whole frame, you can append, prepend, replace, remove, or update specific elements by their id.
Controller returning a Stream
public function addItem(
Request $request
): Response {
$validated = $request->validate([
'list_id' => 'required|integer',
'sku' => 'required|string',
]);
$success = $this->listItemsService
->addItem($validated);
if (!$success) {
// Return stream with error notice
return response()
->view('lists.partials.turbo-stream-error',
['message' => 'Item not found.'])
->header('Content-Type',
'text/vnd.turbo-stream.html');
}
$list = $this->listsService->getList(
$validated['list_id'],
$request->user()->current_store_id
);
return response()
->view('lists.partials.turbo-stream-update',
compact('list'))
->header('Content-Type',
'text/vnd.turbo-stream.html');
}
The stream partial
<!-- Replace the list-info frame -->
<turbo-stream action="replace" target="list-info">
<template>
@include('lists.partials.list-info-frame')
</template>
</turbo-stream>
<!-- Replace the list-items frame -->
<turbo-stream action="replace" target="list-items">
<template>
@include('lists.partials.list-items-frame')
</template>
</turbo-stream>
@if(isset($message))
<!-- Append a toast notification -->
<turbo-stream action="append"
target="notifications">
<template>
@include('partials.notice',
['message' => $message])
</template>
</turbo-stream>
@endif
Stream actions reference
| Action | Effect |
|---|---|
replace | Replaces the element with matching id |
append | Appends HTML inside the target element |
prepend | Prepends HTML inside the target element |
remove | Removes the element with matching id |
update | Replaces the inner content only |
Echo broadcast streams: the custom element <turbo-echo-stream-source> in resources/js/elements/turbo-echo-stream-tag.js subscribes to a Laravel Echo channel and dispatches stream HTML received via WebSocket — enabling real-time DOM updates with no polling.
Stimulus Controllers — JavaScript Sprinkles
Stimulus provides minimal, declarative JavaScript behaviour. It connects HTML attributes to controller classes — no component tree, no virtual DOM. Stimulus enhances existing HTML; Turbo handles all navigation.
Naming and registration
import { Stimulus } from "libs/stimulus";
import { eagerLoadControllersFrom }
from "@hotwired/stimulus-loading";
// All files matching *_controller.js under
// the controllers/ folder are auto-registered
eagerLoadControllersFrom("controllers", Stimulus);
// File: lists_show_controller.js
// → data-controller="lists-show"
// File: bridge/toast_controller.js
// → data-controller="bridge--toast"
// File: sidebar_controller.js
// → data-controller="sidebar"
Connecting to HTML
<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) }}"
>
<button
data-action="lists-show#delete"
>Delete</button>
<span
data-lists-show-target="title"
>{{ $list->name }}</span>
</div>
Controller anatomy
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
// Declared targets become
// this.titleTarget, this.titleTargets
static targets = ["title"];
// Declared values become
// this.editUrlValue, this.deleteUrlValue
static values = {
editUrl: String,
deleteUrl: String,
};
// Called when controller connects to DOM
connect() {
console.log("lists-show connected");
}
// Use Turbo.visit instead of location.href
edit() {
window.Turbo.visit(this.editUrlValue);
}
async delete() {
await fetch(this.deleteUrlValue, {
method: "DELETE",
headers: {
"X-CSRF-TOKEN": document
.querySelector(
'meta[name="csrf-token"]'
).content,
},
});
window.Turbo.visit(
this.listsIndexUrlValue,
{ action: "replace" }
);
}
}
Bridge controllers under controllers/bridge/ integrate with Hotwire Native (iOS/Android), letting the server trigger native UI elements like dropdown menus and toasts.
Blade Component Architecture
All UI is built from reusable Blade components under resources/views/components/. Components are organised by function — never by page. Inline SVGs are forbidden; always use an icon component.
Component categories
layouts/
app, auth, desktop-sidebar — page shells.
common/
page-header, card, badge, carousel, confirm-modal, tab-navigation, top-bar-menu.
common/icons/
One file per icon. 50+ SVG icons as components: package, search, edit, trash, spinner, etc.
form/
text-input, select, label, checkbox, toggle, textarea-input, button/primary, button/danger, button/outline.
Usage examples
<x-layouts.app :title="'Lists'">
<x-common.page-header title="My Lists" />
<x-common.card>
<x-text.heading>Active Lists</x-text.heading>
<x-form.text-input
name="search"
placeholder="Search lists…"
/>
<x-form.button.primary type="submit">
Search
</x-form.button.primary>
</x-common.card>
</x-layouts.app>
Component structure — the pattern
@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>
<!-- ✅ CORRECT: always use icon components -->
<x-icons.search class="w-4 h-4 text-gray-400" />
<x-icons.trash class="w-5 h-5 text-red-500" />
<x-icons.spinner class="w-6 h-6 animate-spin" />
<!-- ❌ WRONG: never inline SVG in Blade -->
<svg class="w-4 h-4" fill="none" …>
<path d="M21 21l-6-6m2-5…" />
</svg>
Creating a new icon: add a file to resources/views/components/icons/, follow the @props pattern with $attributes->merge, and use it immediately as <x-icons.your-icon>.
Tailwind CSS & DaisyUI
The frontend is built with Tailwind CSS 3 and DaisyUI 3, compiled via Laravel Mix. DaisyUI provides semantic tokens that automatically adapt to light and dark mode — hardcoded color classes are never used.
Build pipeline
mix.postCss(
'resources/css/app.css',
'public/dist/css',
[tailwindcss('./tailwind.config.js'),
autoprefixer()]
).version();
// JS is served via importmap, not bundled
mix.copyDirectory(
'resources/js', 'public/js'
);
DaisyUI themes
daisyui: {
themes: [
{
light: {
...require('daisyui/src/theming/themes')
['[data-theme=light]'],
primary: '#bf0004', // RK brand red
'primary-content': '#ffffff',
},
},
{
dark: {
...require('daisyui/src/theming/themes')
['[data-theme=dark]'],
primary: '#bf0004',
'primary-content': '#ffffff',
},
},
],
darkTheme: 'dark', // maps OS dark → this theme
},
Semantic classes — always use these
| Semantic class | Instead of |
|---|---|
bg-base-100 | bg-white / bg-gray-950 |
bg-base-200 | bg-gray-50 / bg-gray-900 |
text-base-content | text-gray-900 / text-white |
bg-primary | bg-red-600 |
border-base-content/10 | border-gray-200 |
DaisyUI component classes
<!-- Buttons -->
<button class="btn btn-primary">Save</button>
<button class="btn btn-ghost">Cancel</button>
<button class="btn btn-error">Delete</button>
<!-- Card -->
<div class="card bg-base-100
border border-base-content/10">
<div class="card-body">…</div>
</div>
<!-- Badge -->
<span class="badge badge-primary">New</span>
<!-- Input -->
<input class="input input-bordered w-full"
type="text">
Dark mode is automatic. DaisyUI's darkTheme: 'dark' maps prefers-color-scheme: dark to the configured dark theme. Any component using semantic classes adapts with zero extra code.
Hotwire Native Integration
The same Laravel web application powers iOS and Android native apps via Hotwire Native. The web app detects native visits and adapts its output — hiding web-only chrome and enabling native bridge components.
Detecting native visits (PHP)
// Tell Turbo Laravel to use the
// partials subfolder convention
Turbo::usePartialsSubfolderPattern();
<body
class="{{ Turbo::isHotwireNativeVisit()
? 'hotwire-native' : '' }}"
data-controller="sidebar"
>
...
</body>
EnsureDeviceAccess middleware
All routes are protected by this middleware. It checks for the rk_lens_access HTTP header or a RKAccess/{token} User-Agent substring. Unauthorized API requests return 404; unauthorized web requests redirect to a blocked page.
Bridge Stimulus controllers
Bridge controllers translate web UI events into native UI actions. They run in both web and native contexts but only trigger native behaviour when inside a Hotwire Native app.
<div
data-controller="bridge--toast flash"
data-turbo-temporary
>
<!-- bridge--toast: tells the native app
to show a native toast notification -->
<!-- flash: removes the element after the
CSS animation ends (web fallback) -->
<p>{{ $message }}</p>
</div>
<!-- Hides native navigation bars
during form submissions -->
<form
data-controller="navigation"
data-action="
turbo:submit-start->navigation#submitStart
turbo:submit-end->navigation#submitEnd
"
>...</form>
One codebase, two platforms: the web app runs identically in a browser and inside the iOS/Android native shell. Bridge controllers are the only native-specific code — and they degrade gracefully to web behaviour when the native bridge is absent.
End-to-End Request Flow
Every interaction — a link click, a form submit, or a native action — follows this path. Each layer has one responsibility and communicates with the next through a well-defined contract.
Complete flow — browser to database and back
<body> or a <turbo-frame>EnsureDeviceAccess → auth → store.selectedKey Takeaways
The Rural King stack is server-driven. The server owns the UI. Turbo handles navigation and DOM updates. Stimulus adds targeted behaviour. The result is a rich, responsive application with minimal custom JavaScript.
Backend rules
- Controllers only validate + call services + return responses
- Services orchestrate use cases — never touch the DB directly
- Repositories implement typed interfaces — swap implementations without changing callers
- Always escape inputs with
$this->db->safe()before SQL interpolation - DTOs are readonly — construct with
fromArray(), serialize withtoArray() web.phpfor views/redirects,api.phpfor JSON — never mix
Frontend rules
- Use Turbo Drive for navigation — no custom AJAX for page transitions
- Use Turbo Frames for partial updates — no manual DOM manipulation
- Return Turbo Streams for precise mutations —
replace,append,remove - Use Stimulus for behaviour —
data-controller,data-action,data-*-target - Always use icon components — never inline SVGs in Blade
- Always use DaisyUI semantic tokens — never hardcode colors
Course summary: Controller + Service + Repository + DTO form the backend. Turbo Drive + Turbo Frames + Turbo Streams handle the UI layer. Stimulus adds targeted JS behaviour. DaisyUI + Tailwind style everything with automatic dark mode. One Laravel app powers the web, iOS, and Android via Hotwire Native.