clqms-be/.agent/workflows/php-alpinejs-pattern.md
2025-12-24 16:42:07 +07:00

20 KiB

description
description
PHP + Alpine.js SPA-like Application Pattern (CodeIgniter 4 + DaisyUI)

PHP + Alpine.js Application Pattern

This workflow describes how to build web applications using PHP (CodeIgniter 4) for backend with Alpine.js + DaisyUI + TailwindCSS for frontend, creating an SPA-like experience with server-rendered views.

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)
  • Beautiful UI with DaisyUI/TailwindCSS
  • JWT-based authentication

Technology Stack

Layer Technology
Backend CodeIgniter 4 (PHP 8.1+)
Frontend Alpine.js + DaisyUI 5 + TailwindCSS
Database MySQL/MariaDB
Auth JWT (stored in HTTP-only cookies)
Icons FontAwesome 7

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
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
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
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:

{
  "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
<!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.

<?= $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.

<!-- 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:

`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

Purpose Color TailwindCSS
Primary Action Emerald bg-emerald-600
Secondary Slate bg-slate-800
Danger Red bg-red-500
Info Blue bg-blue-500
Warning Amber bg-amber-500

5.2 Component Patterns

  • Cards: bg-white rounded-xl border border-slate-100 shadow-sm
  • Buttons: Use DaisyUI btn with custom colors
  • Inputs: Use DaisyUI input input-bordered
  • Modals: Use DaisyUI modal with custom backdrop

5.3 Icons

Use FontAwesome 7 with consistent sizing:

  • Navigation: text-sm
  • 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:

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:

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

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

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