- Add BaseModel with automatic camel/snake case conversion - Add stringcase_helper with camel_to_snake(), snake_to_camel() functions - Update all models to extend BaseModel for consistent data handling - Update API controllers with standardized JSON response format - Remove legacy v1 PHP application directory - Consolidate documentation into AGENTS.md, delete VIEWS_RULES.md
19 KiB
19 KiB
AGENTS.md - AI Agent Guidelines for [PROJECT NAME]
AI Agent Guidelines
- Readability: Write code that is easy to read and understand.
- Maintainability: Write code that is easy to maintain and update.
- Performance: Write code that is fast and efficient.
- Security: Write code that is secure and protected against attacks.
- Testing: Write code that is tested and verified.
Technology Stack
| Layer | Technology |
|---|---|
| Backend | CodeIgniter 4 (PHP 8.1+) |
| Frontend | Alpine.js + TailwindCSS |
| Database | MySQL/MariaDB |
Key Files & Locations
Backend
app/Controllers/ # API & page controllers
app/Models/ # Eloquent-style models
app/Database/Migrations/ # Schema definitions
app/Config/Routes.php # All routes defined here
app/Helpers/ # Helper functions
Frontend
app/Views/ # PHP views with Alpine.js
app/Views/layout/ # Base templates
public/ # Static assets (css, js)
Coding Conventions
PHP / CodeIgniter 4
-
Controllers extend
BaseControllerand useResponseTrait -
Models extend
App\Models\BaseModel(custom base with auto camel/snake conversion) -
Soft deletes are enabled on all tables (
deleted_at) -
Timestamps are automatic (
created_at,updated_at) -
Validation happens in controllers, not models
-
JSON API responses follow this structure:
return $this->respond([ 'status' => 'success', 'message' => 'fetch success', 'data' => $rows ], 200); -
Use camelCase for input/output, snake_case for database:
- Controllers convert camel→snake before insert/update
- Models convert snake→camel after fetch
- Use helper functions:
camel_to_snake(),camel_to_snake_array(),snake_to_camel()
Database
- Primary keys:
{table_singular}_id(e.g.,item_id,pat_id) - Foreign keys: Match the referenced primary key name
- Naming: All lowercase, underscores
- Soft deletes: All tables have
deleted_atDATETIME column - Master data tables: Prefix with
master_(e.g.,master_items) - Timestamps:
created_at,updated_atDATETIME columns - Unique constraints: Add on code fields (e.g.,
item_code)
Frontend / Alpine.js
- x-data on container elements
- Fetch API for AJAX calls (no jQuery)
- DaisyUI components for UI elements
- camelCase for JavaScript, snake_case for PHP/DB
- Modals with x-show and x-transition
- ES6 modules importing Alpine from
app.js
File Naming
| Component | Pattern | Example |
|---|---|---|
| Controller | PascalCase + Controller |
ItemsController |
| Model | PascalCase + Model |
ItemsModel |
| Migration | YYYY-MM-DD-XXXXXX_Description.php |
2026-01-15-000001_Items.php |
| View | module/action.php |
items/index.php |
| Helper | snake_case + _helper.php |
stringcase_helper.php |
| Filter | PascalCase + Filter.php |
JwtAuthFilter.php |
Directory Structure
app/
├── Config/
│ ├── Routes.php
│ └── Filters.php
├── Controllers/
│ ├── BaseController.php
│ ├── ItemsController.php
│ └── Master/
│ └── ItemsController.php
├── Database/
│ └── Migrations/
├── Filters/
│ └── JwtAuthFilter.php
├── Helpers/
│ ├── stringcase_helper.php
│ └── utc_helper.php
├── Models/
│ ├── BaseModel.php
│ ├── ItemsModel.php
│ └── Master/
│ └── ItemsModel.php
└── Views/
├── layout/
│ ├── main_layout.php
│ └── form_layout.php
├── items/
│ ├── items_index.php
│ └── dialog_items_form.php
└── login.php
Common Tasks
Adding a New Master Data Entity
- Create migration in
app/Database/Migrations/ - Create model in
app/Models/[Module]/ - Create controller in
app/Controllers/[Module]/ - Add routes in
app/Config/Routes.php - Create view in
app/Views/[module]/
Adding a New API Endpoint
// In Routes.php
$routes->get('api/resource', 'ResourceController::index');
$routes->get('api/resource/(:num)', 'ResourceController::show/$1');
$routes->post('api/resource', 'ResourceController::create');
$routes->patch('api/resource/(:num)', 'ResourceController::update/$1');
Controller Template
<?php
namespace App\Controllers\Module;
use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Module\ItemModel;
class ItemsController extends BaseController {
use ResponseTrait;
protected $model;
protected $rules;
public function __construct() {
$this->model = new ItemsModel();
$this->rules = [
'itemCode' => 'required|min_length[1]',
'itemName' => 'required',
];
}
public function index() {
$keyword = $this->request->getGet('keyword');
try {
$rows = $this->model->getItems($keyword);
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $rows
], 200);
} catch (\Exception $e) {
return $this->failServerError('Exception: ' . $e->getMessage());
}
}
public function show($id = null) {
try {
$rows = $this->model->where('item_id', $id)->findAll();
if (empty($rows)) {
return $this->respond([
'status' => 'success',
'message' => 'data not found.'
], 200);
}
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $rows
], 200);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function create() {
$input = $this->request->getJSON(true);
$input = camel_to_snake_array($input);
if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$id = $this->model->insert($input, true);
return $this->respondCreated([
'status' => 'success',
'message' => $id
]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function update($id = null) {
$input = $this->request->getJSON(true);
$input = camel_to_snake_array($input);
if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$this->model->update($id, $input);
return $this->respondCreated([
'status' => 'success',
'message' => 'update success',
'data' => $id
]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
}
Model Template
<?php
namespace App\Models\Module;
use App\Models\BaseModel;
class ItemsModel extends BaseModel {
protected $table = 'module_items';
protected $primaryKey = 'item_id';
protected $allowedFields = [
'item_code',
'item_name',
'created_at',
'updated_at',
'deleted_at'
];
protected $useTimestamps = true;
protected $useSoftDeletes = true;
public function getItems($keyword = null) {
if ($keyword) {
return $this->groupStart()
->like('item_code', $keyword)
->orLike('item_name', $keyword)
->groupEnd()
->findAll();
}
return $this->findAll();
}
}
Migration Template
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class ModuleItems extends Migration {
public function up() {
$this->forge->addField([
'item_id' => [
'type' => 'int',
'unsigned' => true,
'auto_increment' => true
],
'item_code' => [
'type' => 'VARCHAR',
'constraint' => 50
],
'item_name' => [
'type' => 'VARCHAR',
'constraint' => 150
],
'created_at' => [
'type' => 'DATETIME',
'null' => true
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true
],
'deleted_at' => [
'type' => 'DATETIME',
'null' => true
]
]);
$this->forge->addKey('item_id', true);
$this->forge->addUniqueKey('item_code');
$this->forge->createTable('module_items');
}
public function down() {
$this->forge->dropTable('module_items', true);
}
}
Routes Template
<?php
use CodeIgniter\Router\RouteCollection;
$routes = get('/login', 'PagesController::login');
$routes->post('/login', 'AuthController::login');
$routes->get('/logout', 'AuthController::logout');
$routes->group('', ['filter' => 'jwt-auth'], function ($routes) {
$routes->get('/', 'PagesController::dashboard');
$routes->get('/module', 'PagesController::module');
$routes->get('/master/items', 'PagesController::masterItems');
});
$routes->group('api', function ($routes) {
$routes->get('module/items', 'Module\ItemsController::index');
$routes->get('module/items/(:num)', 'Module\ItemsController::show/$1');
$routes->post('module/items', 'Module\ItemsController::create');
$routes->patch('module/items/(:num)', 'Module\ItemsController::update/$1');
});
View Template (Index with Alpine.js)
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content"); ?>
<main class="flex-1 p-6 overflow-auto bg-slate-50/50" x-data="items()">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800 tracking-tight">Items</h1>
<p class="text-slate-500 text-sm mt-1">Manage your items</p>
</div>
<button
class="btn btn-sm gap-2 shadow-sm border-0 bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-700 hover:to-blue-600 text-white transition-all duration-200"
@click="showForm()"
>
<i class="fa-solid fa-plus"></i> New Item
</button>
</div>
<div class="bg-white rounded-xl border border-slate-100 shadow-sm p-4 mb-6">
<div class="flex items-center gap-3">
<div class="relative flex-1 max-w-md">
<i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm"></i>
<input
type="text"
placeholder="Search by name or code..."
class="w-full pl-10 pr-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
x-model="keyword"
@keyup.enter="fetchList()"
/>
</div>
<button
class="px-4 py-2.5 text-sm font-medium bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-all duration-200 flex items-center gap-2"
@click="fetchList()"
>
<i class="fa-solid fa-magnifying-glass text-xs"></i> Search
</button>
</div>
</div>
<div class="bg-white rounded-xl border border-slate-100 shadow-sm overflow-hidden">
<template x-if="list">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wider">
<tr>
<th class="py-3 px-5 font-semibold">Code</th>
<th class="py-3 px-5 font-semibold">Name</th>
<th class="py-3 px-5 font-semibold text-right">Action</th>
</tr>
</thead>
<tbody class="text-slate-600 divide-y divide-slate-100">
<template x-for="item in list" :key="item.itemId">
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="py-3 px-5">
<span class="font-mono text-xs bg-slate-100 text-slate-600 px-2 py-1 rounded" x-text="item.itemCode"></span>
</td>
<td class="py-3 px-5 font-medium text-slate-700" x-text="item.itemName"></td>
<td class="py-3 px-5 text-right">
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg transition-colors"
@click="showForm(item.itemId)"
>
<i class="fa-solid fa-pencil"></i> Edit
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
<?= $this->include('module/items/dialog_items_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("items", () => ({
loading: false,
showModal: false,
errors: {},
error: null,
part: "items",
keyword: "",
list: null,
item: null,
form: {
itemCode: "",
itemName: "",
},
async fetchList() {
this.loading = true;
this.error = null;
this.list = null;
try {
const params = new URLSearchParams({ keyword: this.keyword });
const response = await fetch(`${window.BASEURL}api/module/${this.part}?${params}`, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load data");
const data = await response.json();
this.list = data.data;
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
},
async loadData(id) {
this.loading = true;
try {
const response = await fetch(`${window.BASEURL}api/module/${this.part}/${id}`, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load item");
const data = await response.json();
this.form = data.data[0];
} catch (err) {
this.error = err.message;
this.form = {};
} finally {
this.loading = false;
}
},
async showForm(id = null) {
this.showModal = true;
if (id) {
await this.loadData(id);
} else {
this.form = {};
}
},
closeModal() {
this.showModal = false;
this.form = {};
},
validate() {
this.errors = {};
if (!this.form.itemCode) this.errors.itemCode = "Code is required.";
if (!this.form.itemName) this.errors.itemName = "Name is required.";
return Object.keys(this.errors).length === 0;
},
async save() {
if (!this.validate()) return;
this.loading = true;
let method = '';
let url = '';
if (this.form.itemId) {
method = 'patch';
url = `${BASEURL}api/module/${this.part}/${this.form.itemId}`;
} else {
method = 'post';
url = `${BASEURL}api/module/${this.part}`;
}
try {
const res = await fetch(url, {
method: method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
const data = await res.json();
if (data.status === 'success') {
alert("Data saved successfully!");
this.closeModal();
this.list = null;
} else {
alert(data.message || "Something went wrong.");
}
} catch (err) {
console.error(err);
alert("Failed to save data.");
} finally {
this.loading = false;
}
}
}));
});
Alpine.start();
</script>
<?= $this->endSection(); ?>
Running Migrations
php spark migrate # Run all pending
php spark migrate:rollback # Rollback last batch
php spark migrate:refresh # Rollback all + re-run
Testing
# Run all tests
./vendor/bin/phpunit
# Run specific test file
./vendor/bin/phpunit tests/unit/SomeTest.php
Things to Avoid
- Don't use jQuery - Use Alpine.js or vanilla JS
- Don't over-engineer - This is a "no-nonsense" project
- Don't skip soft deletes - Always use
deleted_at - Don't hardcode - Use
.envfor configuration - Don't mix concerns - Controllers handle HTTP, Models handle data
- Don't forget camel/snake conversion - Frontend uses camelCase, DB uses snake_case
Questions to Ask Before Making Changes
- Does this follow the existing patterns?
- Is there a simpler way to do this?
- Did I add the route?
- Did I handle errors gracefully?
- Does the API response match the standard format?
- Did I convert camelCase to snake_case before DB operations?
- Did I convert snake_case to camelCase after fetching?
Post-Change Requirements
After every significant code change, update README.md:
| Change Type | README Section to Update |
|---|---|
| New Controller | Project Structure, API Endpoints |
| New Model | Project Structure |
| New Migration/Table | Database Schema |
| New View | Project Structure |
| New Route | API Endpoints |
| New Command | Development Commands |
| Config Change | Setup |
Keep the README accurate and up-to-date with the actual codebase.