2025-12-24 16:42:07 +07:00
---
2025-12-30 14:30:35 +07:00
description: PHP + Alpine.js SPA-like Application Pattern (CodeIgniter 4 + Custom Tailwind)
2025-12-24 16:42:07 +07:00
---
# PHP + Alpine.js Application Pattern
2025-12-30 14:30:35 +07:00
This workflow describes how to build web applications using **PHP (CodeIgniter 4)** for backend with **Alpine.js + Custom Tailwind CSS** for frontend, creating an SPA-like experience with server-rendered views.
2025-12-24 16:42:07 +07:00
## Philosophy
**"No-nonsense"** - Keep it simple, avoid over-engineering. This pattern gives you:
- Fast development with PHP backend
- Reactive UI with Alpine.js (no heavy framework overhead)
2025-12-30 14:30:35 +07:00
- Beautiful UI with custom Tailwind CSS design system
2025-12-24 16:42:07 +07:00
- JWT-based authentication
---
## Technology Stack
| Layer | Technology |
|-------|------------|
| Backend | CodeIgniter 4 (PHP 8.1+) |
2025-12-30 14:30:35 +07:00
| Frontend | Alpine.js + Custom Tailwind CSS |
2025-12-24 16:42:07 +07:00
| Database | MySQL/MariaDB |
| Auth | JWT (stored in HTTP-only cookies) |
2025-12-30 14:30:35 +07:00
| Icons | FontAwesome 6+ |
2025-12-24 16:42:07 +07:00
---
## Project Structure
```
project/
├── app/
│ ├── Config/
│ │ └── Routes.php # All routes (pages + API)
│ ├── Controllers/
│ │ ├── BaseController.php # Base controller
│ │ ├── PagesController.php # Page routes (returns views)
│ │ └── [Resource]Controller.php # API controllers
│ ├── Models/
│ │ └── [Resource]Model.php # Database models
│ ├── Filters/
│ │ └── JwtAuthFilter.php # JWT authentication filter
│ └── Views/
│ ├── layout/
│ │ └── main_layout.php # Base layout with sidebar
│ ├── [module]/
│ │ ├── [module]_index.php # Main page with x-data
│ │ ├── dialog_[name].php # Modal dialogs (included)
│ │ └── drawer_[name].php # Drawer components
│ └── login.php
├── public/
│ ├── index.php
│ └── assets/
│ ├── css/output.css # Compiled TailwindCSS
│ └── js/app.js # Alpine.js setup
└── .env # Environment config
```
---
## 1. Backend Patterns
### 1.1 Routes Structure (`app/Config/Routes.php`)
Routes are split into:
1. **Public routes** - Login, logout, auth check
2. **Protected page routes** - Views (with `jwt-auth` filter)
3. **API routes** - RESTful JSON endpoints
```php
< ?php
use CodeIgniter\Router\RouteCollection;
/** @var RouteCollection $routes */
// Public routes
$routes->get('/login', 'PagesController::login');
$routes->post('/login', 'AuthController::login');
$routes->get('/logout', 'AuthController::logout');
// Protected page routes (returns views)
$routes->group('', ['filter' => 'jwt-auth'], function ($routes) {
$routes->get('/', 'PagesController::dashboard');
$routes->get('/patients', 'PagesController::patients');
$routes->get('/requests', 'PagesController::requests');
// Master data pages
$routes->get('/master/doctors', 'PagesController::masterDoctors');
});
// API routes (returns JSON)
$routes->group('api', function ($routes) {
// Resource: patients
$routes->get('patients', 'PatientsController::index');
$routes->get('patients/(:num)', 'PatientsController::show/$1');
$routes->post('patients', 'PatientsController::create');
$routes->patch('patients/(:num)', 'PatientsController::update/$1');
// Resource: [resourceName]
// Follow same pattern: index, show, create, update
});
```
### 1.2 Pages Controller (`app/Controllers/PagesController.php`)
This controller ONLY returns views. No business logic.
```php
< ?php
namespace App\Controllers;
class PagesController extends BaseController {
public function dashboard() {
return view('dashboard', [
'pageTitle' => 'Dashboard',
'activePage' => 'dashboard'
]);
}
public function patients() {
return view('patients/patients_index', [
'pageTitle' => 'Patients',
'activePage' => 'patients'
]);
}
public function requests() {
return view('requests/requests_index', [
'pageTitle' => 'Lab Requests',
'activePage' => 'requests'
]);
}
}
```
### 1.3 API Controller Pattern (`app/Controllers/[Resource]Controller.php`)
API controllers handle CRUD operations and return JSON.
```php
< ?php
namespace App\Controllers;
use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\PatientsModel;
class PatientsController extends BaseController {
use ResponseTrait;
protected $model;
protected $rules;
public function __construct() {
$this->model = new PatientsModel();
$this->rules = [
'firstName' => 'required|min_length[2]',
'lastName' => 'required|min_length[2]',
];
}
/**
* GET /api/patients
* List all with optional search
*/
public function index() {
$keyword = $this->request->getGet('keyword');
try {
$rows = $this->model->search($keyword);
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $rows
], 200);
} catch (\Exception $e) {
return $this->failServerError('Exception: ' . $e->getMessage());
}
}
/**
* GET /api/patients/:id
*/
public function show($id = null) {
try {
$row = $this->model->find($id);
if (empty($row)) {
return $this->respond(['status' => 'success', 'message' => 'not found'], 200);
}
return $this->respond(['status' => 'success', 'data' => $row], 200);
} catch (\Exception $e) {
return $this->failServerError('Error: ' . $e->getMessage());
}
}
/**
* POST /api/patients
*/
public function create() {
$input = $this->request->getJSON(true);
if (!$this->validateData($input, $this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
$input = camel_to_snake_array($input); // Convert keys to snake_case
try {
$id = $this->model->insert($input);
return $this->respondCreated([
'status' => 'success',
'message' => 'Created successfully',
'data' => ['id' => $id]
]);
} catch (\Exception $e) {
return $this->failServerError('Error: ' . $e->getMessage());
}
}
/**
* PATCH /api/patients/:id
*/
public function update($id = null) {
$input = $this->request->getJSON(true);
$input = camel_to_snake_array($input);
try {
$this->model->update($id, $input);
return $this->respond(['status' => 'success', 'message' => 'updated']);
} catch (\Exception $e) {
return $this->failServerError('Error: ' . $e->getMessage());
}
}
}
```
### 1.4 Standard API Response Format
Always return this structure:
```json
{
"status": "success|error",
"message": "Human readable message",
"data": {} // or [] for lists
}
```
---
## 2. Frontend Patterns
### 2.1 Base Layout (`app/Views/layout/main_layout.php`)
The layout provides:
- Sidebar navigation with Alpine.js state
- Top navbar with user info
- Content section for page-specific content
- Script section for page-specific JavaScript
```php
<!DOCTYPE html>
< html lang = "en" data-theme = "corporate" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > AppName< / title >
< link href = "<?=base_url();?>assets/css/output.css" rel = "stylesheet" / >
< script src = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/js/all.min.js" > < / script >
< / head >
< body class = "h-screen flex bg-base-100" >
<!-- Sidebar with Alpine.js -->
< aside
x-data="main({ activePage: '<?= esc($activePage ?? '') ?> ' })"
class="w-56 bg-slate-900 text-white flex flex-col"
>
<!-- Navigation links -->
< nav class = "px-3 py-2 space-y-1" >
< a href = "<?=base_url();?>"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg"
:class="page === 'dashboard' ? 'bg-blue-600' : 'hover:bg-slate-700'"
>
< i class = "fa-solid fa-th-large" > < / i >
< span > Dashboard< / span >
< / a >
<!-- More nav items... -->
< / nav >
< / aside >
< div class = "flex-1 flex flex-col overflow-hidden" >
<!-- Top Navbar -->
< nav class = "bg-white border-b px-6 py-3" >
< span class = "font-semibold" > <?= esc($pageTitle ?? 'Dashboard') ?> < / span >
< / nav >
<!-- Page Content -->
<?= $this->renderSection('content') ?>
< / div >
< script >
window.BASEURL = "<?=base_url();?> ";
< / script >
<?= $this->renderSection('script') ?>
< / body >
< / html >
```
### 2.2 Page View Pattern (`app/Views/[module]/[module]_index.php`)
Each page extends the layout and defines its Alpine.js component.
```php
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content") ?>
< main class = "flex-1 p-6 overflow-auto bg-slate-50" x-data = "patients()" >
<!-- Page Header -->
< div class = "flex justify-between items-center mb-6" >
< div >
< h1 class = "text-2xl font-bold text-slate-800" > Patients< / h1 >
< p class = "text-slate-500 text-sm mt-1" > Manage patient records< / p >
< / div >
< button class = "btn btn-sm bg-emerald-600 text-white" @click =" showForm ()" >
< i class = "fa-solid fa-plus" > < / i > New Patient
< / button >
< / div >
<!-- Search & Filter -->
< div class = "bg-white rounded-xl border p-4 mb-6" >
< input
type="text"
placeholder="Search..."
class="input input-bordered w-full max-w-md"
x-model="keyword"
@keyup .enter="fetchList()"
/>
< button class = "btn btn-neutral" @click =" fetchList ()" > Search</ button >
< / div >
<!-- Data List -->
< div class = "bg-white rounded-xl border" >
< template x-if = "list && list.length > 0" >
< template x-for = "item in list" :key = "item.patId" >
< div class = "p-4 border-b hover:bg-slate-50" @click =" fetchItem ( item . patId )" >
< span x-text = "item.firstName + ' ' + item.lastName" > < / span >
< / div >
< / template >
< / template >
< template x-if = "!list || list.length === 0" >
< div class = "p-8 text-center text-slate-400" >
< p > No records found< / p >
< / div >
< / template >
< / div >
<!-- Include dialog components -->
<?php echo $this->include('[module]/dialog_form'); ?>
< / main >
<?= $this->endSection(); ?>
<?= $this->section("script") ?>
< script type = "module" >
import Alpine from '<?= base_url('/assets/js/app.js'); ?> ';
document.addEventListener('alpine:init', () => {
Alpine.data("patients", () => ({
// State
loading: false,
showModal: false,
errors: {},
// Data
list: null,
item: null,
keyword: "",
// Form
form: {
firstName: "",
lastName: "",
birthDate: "",
sex: "M"
},
// Lifecycle
async init() {
await this.fetchList();
},
// Fetch list
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('keyword', this.keyword);
const res = await fetch(`${BASEURL}api/patients?${params}` );
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data;
} catch (err) {
console.error(err);
this.list = [];
} finally {
this.loading = false;
}
},
// Fetch single item
async fetchItem(id) {
this.loading = true;
try {
const res = await fetch(`${BASEURL}api/patients/${id}` );
const data = await res.json();
this.item = data.data;
} catch (err) {
console.error(err);
} finally {
this.loading = false;
}
},
// Form validation
validate() {
const e = {};
if (!this.form.firstName) e.firstName = "First name is required.";
if (!this.form.lastName) e.lastName = "Last name is required.";
this.errors = e;
return Object.keys(e).length === 0;
},
// Show form modal
showForm(id = null) {
this.showModal = true;
if (id) {
this.loadFormData(id);
} else {
this.form = { firstName: "", lastName: "", birthDate: "", sex: "M" };
}
},
// Load data for editing
async loadFormData(id) {
try {
const res = await fetch(`${BASEURL}api/patients/${id}` );
const data = await res.json();
this.form = data.data;
} catch (err) {
console.error(err);
}
},
closeModal() {
this.showModal = false;
this.errors = {};
},
// Save (create or update)
async save() {
if (!this.validate()) return;
try {
let res;
if (this.form.patId) {
res = await fetch(`${BASEURL}api/patients/${this.form.patId}` , {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form)
});
} else {
res = await fetch(`${BASEURL}api/patients` , {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form)
});
}
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
if (data.status === 'success') {
alert("Saved successfully!");
await this.fetchList();
} else {
alert(data.message || "Something went wrong.");
}
} catch (err) {
console.error(err);
alert("Failed to save.");
} finally {
this.closeModal();
}
}
}));
});
Alpine.start();
< / script >
<?= $this->endSection(); ?>
```
### 2.3 Dialog Component Pattern (`app/Views/[module]/dialog_form.php`)
Dialogs are included in the main page and share x-data context.
```php
<!-- Form Modal -->
< dialog id = "form_modal" class = "modal" :class = "showModal && 'modal-open'" >
< div class = "modal-box w-11/12 max-w-2xl bg-white" >
< h3 class = "font-bold text-lg flex items-center gap-2" >
< i class = "fa-solid fa-user text-emerald-500" > < / i >
< span x-text = "form.patId ? 'Edit Patient' : 'New Patient'" > < / span >
< / h3 >
< div class = "py-4" >
<!-- First Name -->
< div class = "form-control mb-4" >
< label class = "label" >
< span class = "label-text font-medium" > First Name < span class = "text-red-500" > *< / span > < / span >
< / label >
< input type = "text" class = "input input-bordered" x-model = "form.firstName" / >
< span class = "text-error text-xs mt-1" x-text = "errors.firstName || ''" > < / span >
< / div >
<!-- Last Name -->
< div class = "form-control mb-4" >
< label class = "label" >
< span class = "label-text font-medium" > Last Name < span class = "text-red-500" > *< / span > < / span >
< / label >
< input type = "text" class = "input input-bordered" x-model = "form.lastName" / >
< span class = "text-error text-xs mt-1" x-text = "errors.lastName || ''" > < / span >
< / div >
<!-- Grid for multiple fields -->
< div class = "grid grid-cols-2 gap-4" >
< div class = "form-control" >
< label class = "label" >
< span class = "label-text font-medium" > Birth Date< / span >
< / label >
< input type = "date" class = "input input-bordered" x-model = "form.birthDate" / >
< / div >
< div class = "form-control" >
< label class = "label" >
< span class = "label-text font-medium" > Sex< / span >
< / label >
< select class = "select select-bordered" x-model = "form.sex" >
< option value = "M" > Male< / option >
< option value = "F" > Female< / option >
< / select >
< / div >
< / div >
< / div >
< div class = "modal-action" >
< button class = "btn btn-ghost" @click =" closeModal ()" > Cancel</ button >
< button class = "btn bg-emerald-600 text-white" @click =" save ()" >
< i class = "fa-solid fa-save mr-1" > < / i > Save
< / button >
< / div >
< / div >
< div class = "modal-backdrop bg-slate-900/50" @click =" closeModal ()" ></ div >
< / dialog >
```
---
## 3. Naming Conventions
### 3.1 Files & Directories
| Type | Convention | Example |
|------|------------|---------|
| Views | `snake_case` | `patients_index.php` |
| Dialogs | `dialog_[name].php` | `dialog_form.php` |
| Drawers | `drawer_[name].php` | `drawer_filter.php` |
| Controllers | `PascalCase` | `PatientsController.php` |
| Models | `PascalCase` | `PatientsModel.php` |
### 3.2 Variables & Keys
| Context | Convention | Example |
|---------|------------|---------|
| PHP/Database | `snake_case` | `pat_id` , `first_name` |
| JavaScript | `camelCase` | `patId` , `firstName` |
| Alpine.js x-data | `camelCase` | `showModal` , `fetchList` |
### 3.3 Primary Keys
Use format: `{table_singular}_id`
| Table | Primary Key |
|-------|-------------|
| `patients` | `pat_id` |
| `requests` | `request_id` |
| `master_tests` | `test_id` |
---
## 4. Database Conventions
### 4.1 Standard Columns
Every table should have:
```sql
`created_at` DATETIME,
`updated_at` DATETIME,
`deleted_at` DATETIME -- Soft deletes
```
### 4.2 Status Codes (Single Character)
| Code | Meaning |
|------|---------|
| `P` | Pending |
| `I` | In Progress |
| `C` | Completed |
| `V` | Validated |
| `X` | Cancelled |
---
## 5. UI/UX Guidelines
### 5.1 Color Palette
2025-12-30 14:30:35 +07:00
Use CSS variables from the custom design system:
| Purpose | CSS Variable | Example |
|---------|--------------|---------|
| Primary | `rgb(var(--color-primary))` | Indigo gradient |
| Success | `rgb(var(--color-success))` | Emerald |
| Warning | `rgb(var(--color-warning))` | Amber |
| Error | `rgb(var(--color-error))` | Red |
| Info | `rgb(var(--color-info))` | Sky |
2025-12-24 16:42:07 +07:00
### 5.2 Component Patterns
2025-12-30 14:30:35 +07:00
- **Cards**: Use `.card` class (glassmorphism effect)
- **Buttons**: Use `.btn .btn-primary` or `.btn-ghost`
- **Inputs**: Use `.input` class
- **Modals**: Use `.modal-overlay` + `.modal-content` with Alpine.js
- **Badges**: Use `.badge .badge-primary` etc.
- **Tables**: Use `.table` class
2025-12-24 16:42:07 +07:00
### 5.3 Icons
2025-12-30 14:30:35 +07:00
Use FontAwesome 6+ with consistent sizing:
- Navigation: `text-sm` or `text-base`
2025-12-24 16:42:07 +07:00
- Buttons: Default size
- Headers: `text-lg` to `text-2xl`
---
## 6. Common Helpers
### 6.1 camel_to_snake_array (PHP Helper)
Convert JavaScript camelCase keys to PHP snake_case:
```php
function camel_to_snake_array(array $data): array {
$result = [];
foreach ($data as $key => $value) {
$snakeKey = strtolower(preg_replace('/[A-Z]/', '_$0', lcfirst($key)));
$result[$snakeKey] = is_array($value) ? camel_to_snake_array($value) : $value;
}
return $result;
}
```
### 6.2 base_url() Usage
Always use `base_url()` for asset and API paths:
```php
// In PHP
< link href = "<?=base_url();?>assets/css/output.css" rel = "stylesheet" / >
// In JavaScript (via global variable)
< script > window . BASEURL = "<?=base_url();?>" ; < / script >
```
---
## 7. Quick Reference: Creating a New Module
### Step 1: Create Route
```php
// app/Config/Routes.php
$routes->get('/products', 'PagesController::products');
$routes->get('api/products', 'ProductsController::index');
$routes->get('api/products/(:num)', 'ProductsController::show/$1');
$routes->post('api/products', 'ProductsController::create');
$routes->patch('api/products/(:num)', 'ProductsController::update/$1');
```
### Step 2: Create Model
```php
// app/Models/ProductsModel.php
namespace App\Models;
class ProductsModel extends BaseModel {
protected $table = 'products';
protected $primaryKey = 'product_id';
protected $allowedFields = ['name', 'sku', 'price'];
}
```
### Step 3: Create Controller
Copy pattern from `PatientsController.php` or `RequestsController.php` .
### Step 4: Create View
Create `app/Views/products/products_index.php` following the page view pattern.
### Step 5: Create Dialog
Create `app/Views/products/dialog_form.php` for add/edit modal.
### Step 6: Add to Navigation
Update `app/Views/layout/main_layout.php` sidebar.
---
## 8. Things to Avoid
1. **Don't use jQuery** - Use Alpine.js or vanilla JS
2. **Don't over-engineer** - Keep it simple
3. **Don't skip soft deletes** - Always use `deleted_at`
4. **Don't hardcode URLs** - Use `base_url()` and `BASEURL`
5. **Don't mix concerns** - Controllers handle HTTP, Models handle data
6. **Don't create separate JS files for each page** - Keep JS inline in views for simplicity
---
## 9. Checklist Before Deploying
- [ ] All routes added to `Routes.php`
- [ ] API responses follow standard format
- [ ] Form validation in both frontend and backend
- [ ] Error handling with user-friendly messages
- [ ] Loading states for async operations
- [ ] Responsive design tested
- [ ] README.md updated