tinyqc/AGENTS.md
mahdahar 18b85815ce refactor: standardize codebase with BaseModel and new conventions
- 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
2026-01-15 10:44:09 +07:00

19 KiB

AGENTS.md - AI Agent Guidelines for [PROJECT NAME]

AI Agent Guidelines

  1. Readability: Write code that is easy to read and understand.
  2. Maintainability: Write code that is easy to maintain and update.
  3. Performance: Write code that is fast and efficient.
  4. Security: Write code that is secure and protected against attacks.
  5. 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

  1. Controllers extend BaseController and use ResponseTrait

  2. Models extend App\Models\BaseModel (custom base with auto camel/snake conversion)

  3. Soft deletes are enabled on all tables (deleted_at)

  4. Timestamps are automatic (created_at, updated_at)

  5. Validation happens in controllers, not models

  6. JSON API responses follow this structure:

    return $this->respond([
      'status' => 'success',
      'message' => 'fetch success',
      'data' => $rows
    ], 200);
    
  7. 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

  1. Primary keys: {table_singular}_id (e.g., item_id, pat_id)
  2. Foreign keys: Match the referenced primary key name
  3. Naming: All lowercase, underscores
  4. Soft deletes: All tables have deleted_at DATETIME column
  5. Master data tables: Prefix with master_ (e.g., master_items)
  6. Timestamps: created_at, updated_at DATETIME columns
  7. Unique constraints: Add on code fields (e.g., item_code)

Frontend / Alpine.js

  1. x-data on container elements
  2. Fetch API for AJAX calls (no jQuery)
  3. DaisyUI components for UI elements
  4. camelCase for JavaScript, snake_case for PHP/DB
  5. Modals with x-show and x-transition
  6. 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

  1. Create migration in app/Database/Migrations/
  2. Create model in app/Models/[Module]/
  3. Create controller in app/Controllers/[Module]/
  4. Add routes in app/Config/Routes.php
  5. 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

  1. Don't use jQuery - Use Alpine.js or vanilla JS
  2. Don't over-engineer - This is a "no-nonsense" project
  3. Don't skip soft deletes - Always use deleted_at
  4. Don't hardcode - Use .env for configuration
  5. Don't mix concerns - Controllers handle HTTP, Models handle data
  6. Don't forget camel/snake conversion - Frontend uses camelCase, DB uses snake_case

Questions to Ask Before Making Changes

  1. Does this follow the existing patterns?
  2. Is there a simpler way to do this?
  3. Did I add the route?
  4. Did I handle errors gracefully?
  5. Does the API response match the standard format?
  6. Did I convert camelCase to snake_case before DB operations?
  7. 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.