These two files are written for AI assistants (Claude, GPT, etc.) working on projects built with
MagratheaPHP2. View them here, or download a copy to hand to your own assistant.
The AI-facing cookbook - how to correctly generate code using MagratheaPHP2.Download skill.md
skills.MD — How to Use MagratheaPHP2 in a Project
This file teaches an AI assistant how to correctly generate code using the MagratheaPHP2 framework. It is a practical, task-oriented cookbook. Read it before writing any PHP code for a project that uses this framework.
Every entry point starts the same way, and in real projects this bootstrap lives in one shared file — conventionally app/_inc.php — that every entry point (public/index.php, public/admin.php) requires. Never skip any step.
Real projects organize domain code as features, not flat models//controls/ folders: one folder per table/domain under app/features/<Name>/, added via ->AddFeature(...) (see §3–4 for what goes inside). Plain ->AddCodeFolder(...) is still used for non-feature folders like admin, api, shared.
<?php
// app/_inc.php
require __DIR__ . "/../vendor/autoload.php";
error_reporting(E_ALL);
ini_set("display_errors", "1");
try {
Magrathea2\MagratheaPHP::Instance()
->MinVersion("2.1.19") // optional, pins a minimum framework version
->AppPath(realpath(dirname(__FILE__))) // this folder (app/) becomes appRoot
->AddCodeFolder(
"admin",
"api",
"api/Authentication",
"api/Controls",
"shared",
)
->AddFeature( // adds features/<Name> + features/<Name>/Base for each"Article", "Author",
)
->Dev() // or ->Prod() in production
->Load(); // reads config/magrathea.conf, relative to appRoot's parent
} catch (Exception $ex) {
\Magrathea2\p_r($ex);
}
Entry points then just require it:
<?php
// public/index.php
require __DIR__ . "/../app/_inc.php";
// ... build and Run() the API class ...
Rules:
AppPath() must be called before Load(); config/magrathea.conf is expected one level aboveAppPath() (i.e. sibling of app/, not inside it)
AddCodeFolder() paths are relative to AppPath
AddFeature("Article", ...) adds <appRoot>/features/Article and <appRoot>/features/Article/Base to the autoloader — see §3 for what lives in each
Use ->Dev() locally, ->Prod() on servers
Call ->StartSession() before any session use (admin panel, etc.)
->Connect() is optional in _inc.php — many projects let each request connect lazily; call it explicitly if you need the DB immediately
Sections are named by environment, not by topic. [general] holds cross-environment settings, including use_environment (which section is active by default — usually overridden by ->Dev() / ->Prod()). Each environment section ([dev], [production], …) repeats the full flat set of keys it needs — there is no [section:env] override/inheritance syntax.
Keys are quoted strings; indentation with a tab under each section header is the convention seen in real configs (not required by the parser, but keep it for consistency).
Reading config in code
use Magrathea2\Config;
// Read a single key from the active environment section$host = Config::Instance()->GetConfig("db_host");
// Read the full active-environment section$env = Config::Instance()->GetConfigSection();
echo$env["db_host"];
Never hardcode credentials. Use $=ENV_VAR_NAME for secrets.
3. Creating a Model
A model maps to one database table, and lives in its own feature folder (added via ->AddFeature("Article", ...) in _inc.php, see §1). This is the convention observed across real production Magrathea2 projects — prefer it over a flat models/ folder.
Each feature is split into a generated Base class and a concrete class:
app/features/Article/Base/ArticleBase.php — holds $dbTable, $dbPk, $dbValues, and FK relations. Marked as generated ("changes can be overwritten through the admin") because Magrathea's admin tooling can regenerate it from the DB schema — but hand-writing it in this exact shape is normal when that tooling isn't in play.
app/features/Article/Article.php — the concrete class, extends Base\ArticleBase, empty unless you add real business-logic methods.
<?php
// app/features/Article/Base/ArticleBase.php## FILE GENERATED BY MAGRATHEA.## This file was automatically generated and changes can be overwritten through the adminnamespace App\Models\Base;
use Magrathea2\iMagratheaModel;
use Magrathea2\MagratheaModel;
class ArticleBase extends MagratheaModel implements iMagratheaModel {
public$id, $title, $body, $author_id, $published, $views;
public$created_at, $updated_at;
protected$autoload = null;
publicfunction __construct($id = 0) {
$this->MagratheaStart();
if (!empty($id)) {
$pk = $this->dbPk;
$this->$pk = $id;
$this->GetById($id);
}
}
publicfunction MagratheaStart() {
$this->dbTable = "articles";
$this->dbPk = "id";
$this->dbValues["id"] = "int";
$this->dbValues["title"] = "string";
$this->dbValues["body"] = "text";
$this->dbValues["author_id"] = "int";
$this->dbValues["published"] = "boolean";
$this->dbValues["views"] = "int";
$this->dbValues["created_at"] = "datetime";
$this->dbValues["updated_at"] = "datetime";
// FK relation example — lazy-loaded getter/setter pair$this->relations["properties"]["Author"] = null;
$this->relations["methods"]["Author"] = "GetAuthor";
$this->relations["lazyload"]["Author"] = "true";
$this->relations["external"]["author_id"] = "\App\Author\Author";
}
publicfunction GetControl() {
returnnew \App\Controls\Base\ArticleControlBase();
}
// >>> relations:publicfunction GetAuthor() {
if ($this->relations["properties"]["Author"] != null) return$this->relations["properties"]["Author"];
$this->relations["properties"]["Author"] = new \App\Author\Author($this->author_id);
return$this->relations["properties"]["Author"];
}
publicfunction SetAuthor($author) {
$this->relations["properties"]["Author"] = $author;
$this->author_id = $author->GetID();
return$this;
}
}
Important: declare every $dbValues field as a public property on the Base class (untyped, comma-declared as above, matching the generator's style). Do NOT use #[\AllowDynamicProperties].
For simple internal tools without the feature/Base convention, a flat single-class model ($dbTable/$dbPk/$dbValues directly in one class extending MagratheaModel) also works — but match whatever convention the rest of the project already uses.
Supported field types
Type
PHP equivalent
int
integer
boolean
bool (stored as TINYINT 0/1)
string
string (VARCHAR etc.)
text
string (TEXT column)
float
float (DECIMAL/FLOAT)
datetime
string in Y-m-d H:i:s format
uuid
string (CHAR(36)), auto-generated UUIDv7 on insert if unset
uuid behaves the same way created_at/updated_at already do: declare $this->dbValues["uuid"] = "uuid"; and the framework fills it in on Insert() (via Uuid::V7()) whenever the property is left empty. An explicitly pre-set value (e.g. from a fixture or migration) is respected and never overwritten. Nothing changes on Update() — UUIDs are immutable once assigned. Requires MagratheaPHP2 2.1.30+.
Using a model
// Create and insert$article = new Article();
$article->title = "Hello World";
$article->body = "My first article.";
$article->published = true;
$article->created_at = now();
$id = $article->Save(); // returns new ID (or ->Insert() explicitly)// Load by PK$article = new Article(42);
echo$article->title;
// Update$article->title = "Updated Title";
$article->Save(); // detects existing PK → UPDATE// Delete$article->Delete();
// Serialize for API response — use ToArray() for flat map, ToJson() for full envelopereturn$article->ToArray();
4. Creating a Control
Every model gets a companion Control class for static data access — same feature-folder Base split as the model.
<?php
// app/features/Article/Base/ArticleControlBase.php## FILE GENERATED BY MAGRATHEA.## This file was automatically generated and changes can be overwritten through the adminnamespace App\Controls\Base;
use Magrathea2\MagratheaModelControl;
class ArticleControlBase extends MagratheaModelControl {
protectedstatic$modelNamespace = "App\\Models\\";
protectedstatic$modelName = "Article";
protectedstatic$dbTable = "articles";
}
<?php
// app/features/Article/ArticleControl.phpnamespace App\Controls;
use Magrathea2\DB\Query;
class ArticleControl extends \App\Controls\Base\ArticleControlBase {
// real query/business-logic methods go here, e.g.:publicfunction GetPublished() {
return$this->GetWhere(["published" => 1]);
}
}
Using a control
use App\Controls\ArticleControl;
// All records$all = ArticleControl::GetAll();
// Filtered$published = ArticleControl::GetWhere(["published" => 1]);
// First match$article = ArticleControl::GetRowWhere(["id" => 42]);
// Custom SQL$recent = ArticleControl::RunQuery(
"SELECT * FROM articles WHERE created_at > '2024-01-01' ORDER BY created_at DESC LIMIT 5"
);
// Count$total = ArticleControl::QueryOne("SELECT COUNT(*) FROM articles WHERE published = 1");
// Paginated (with total — costs an extra COUNT(*) query)$total = 0;
$query = \Magrathea2\DB\Query::Select()
->Obj(new \App\Models\Article())
->Where(["published" => 1])
->Order("created_at DESC");
$page = ArticleControl::RunPagination($query, $total, page: 0, limit: 10);
// Paginated for an API response — no COUNT(*) query, has_more computed via limit+1 trick.// Return the MagratheaPagination directly from a controller action; see §7.$query = \Magrathea2\DB\Query::Select()
->Obj(new \App\Models\Article())
->Where(["published" => 1])
->Order("created_at DESC");
$pagination = ArticleControl::GetPagination($query, page: 0, limit: 10); // MagratheaPagination
5. Building Queries
Use the Query Builder for anything beyond simple GetWhere calls.
Always use Query::Clean() or PrepareAndExecute() for user input:
$safe = Query::Clean($_GET["search"]);
$sql = "SELECT * FROM articles WHERE title LIKE '%$safe%'";
// Better: prepared statement$result = Database::Instance()->PrepareAndExecute(
"SELECT * FROM articles WHERE title LIKE ?",
["s"],
["%{$_GET['search']}%"]
);
6. Creating an API Class
The preferred pattern is to encapsulate all route definitions in a class that extends MagratheaApi. This makes the API reusable — both the HTTP entry point and the Admin ApiExplorer instantiate the same class.
Never store passwords or sensitive PII in the token
Always override GetSecret() to use a config value, not a hardcoded string
GetAuthorizationToken() throws if the header is missing — call it inside try/catch or rely on the route's auth parameter
9. Caching
Cache($name, $data=null) does not take the response payload as its second argument. $data is an optional key suffix/discriminator — it gets appended to $name (as "{$name}-{$data}") to build the cache file's handle, e.g. to key the cache by an id or page number. Never pass a JSON blob or the computed result as $data.
Calling $this->Cache($name, $data) only peeks: if a cache file for that key already exists, it outputs it and kills execution (die) right there. It never writes anything itself. The actual write-to-disk happens automatically later, when the framework serializes the controller's return value to JSON — no second manual call is needed or correct.
In a controller (recommended pattern)
publicfunction List(): array {
// Serve from cache if available (outputs and exits on hit).// Saving happens automatically when the response is emitted.$this->Cache("articles_published");
$articles = ArticleControl::GetWhere(["published" => 1]);
return array_map(fn($a) => $a->ToArray(), $articles);
}
publicfunction View(array$params = []): array {
// $data suffix keys the cache per-id$this->Cache("article", $params["id"]);
$article = new ArticleControl($params["id"]);
return$article->ToArray();
}
publicfunction Create(array$data = []): array {
// ... create ...// Invalidate related cache (same $name/$data used to build the key)$this->CacheClear("articles_published");
return$article->ToArray();
}
Invalidate by pattern (when many related keys exist)
$this->CacheClearPattern("articles_*");
Cache key naming convention
Always include parameters that affect the result. Prefer the $data suffix param for a single discriminator (id, page); bake the rest into $name:
use Magrathea2\Logger;
Logger::Instance()->Log("Order #$orderId processed by user #$userId");
try {
// something risky
} catch (\Exception $e) {
Logger::Instance()->LogError($e);
}
Logger::Instance()->SetLogFile("payments")->Log("Payment success: $amount");
Debugging (development only)
use Magrathea2\Debugger;
Debugger::Instance()->Info("Cache miss for: articles_published");
Debugger::Instance()->Add(["user_id" => $id, "role" => $role]);
Debugger::Instance()->Show();
11. Sending Email
Native mail()
use Magrathea2\MagratheaMail;
$mail = new MagratheaMail();
$mail->SetTo($user->email)
->SetFrom("noreply@myapp.com")
->SetSubject("Welcome!")
->SetHTMLMessage("<h1>Hi!</h1>")
->SetTXTMessage("Hi!")
->Send();
SMTP
use Magrathea2\MagratheaMailSMTP;
$mail = new MagratheaMailSMTP(); // uses [mail] section from config$mail->SetTo($user->email)->SetSubject("Reset")->SetHTMLMessage($html)->Send();
12. Admin Panel — Entry Point
The admin entry point bootstraps Magrathea (including StartSession()), then hands off to AdminManager.
LoadFeatures() — calls AddCrudFeature() for each CRUD admin class
BuildMenu() — constructs the sidebar; use CreateTitle() for section headers, AddFeaturesMenu() to auto-include CRUD items, always end with GetLogoutMenuItem()
AddFeature() vs AddCrudFeature() — use AddCrudFeature for AdminCrudObject subclasses, AddFeature for everything else
14. Admin Panel — CRUD Features
Each model gets a companion CRUD admin class extending AdminCrudObject.
[ ] AppPath() is set with __DIR__ or an absolute path
[ ] All class folders registered with AddCodeFolder() (including admin/ for admin panels)
[ ] ->Load()->Connect() is called before any DB operation
[ ] ->StartSession() called when admin panel is used
[ ] Mode is ->Dev() for local, ->Prod() for server
Models
[ ] $dbTable is set
[ ] $dbValues includes all columns with correct types
[ ] $dbPk is set if primary key is not "id"
[ ] Each $dbValues field has an explicit typed public property declaration
[ ] No #[\AllowDynamicProperties] attribute used
[ ] If the project uses UUIDs as external ids, the field is declared as "uuid" type, not hand-rolled
Controls
[ ] $modelName, $modelNamespace, $dbTable are all set
[ ] Namespace matches the actual file location
API Class
[ ] Extends MagratheaApi
[ ] Constructor calls Initialize()
[ ] Routes grouped into domain methods
[ ] Run() is NOT called inside the class — only in the HTTP entry point
API Controllers
[ ] Every controller extends MagratheaApiControl
[ ] All error cases throw MagratheaApiException, not generic exceptions
[ ] User input never directly interpolated into SQL
[ ] GetPost() / GetPut() used (not $_POST directly)
[ ] Returns ->ToArray() not ->ToJson() for API responses
Admin Class
[ ] Extends \Magrathea2\Admin\Admin and implements \Magrathea2\Admin\iAdmin
[ ] SetFeatures() calls parent::SetFeatures() first
[ ] LoadApi() uses SetApi(new YourApiClass()) — not require
[ ] BuildMenu() ends with GetLogoutMenuItem()
[ ] AddCrudFeature() used for AdminCrudObject subclasses
Admin CRUD Features
[ ] Extends AdminCrudObject
[ ] $modelName, $modelNamespace, $controlName, $controlNamespace all set
[ ] $label and $icon set for UI
Config
[ ] Secrets use $=ENV_VAR — never hardcoded
[ ] Config file at config/magrathea.conf relative to AppPath
General
[ ] now() used for datetime fields
[ ] Cache invalidated when data is mutated
[ ] Singletons accessed via ::Instance(), never new ClassName()
---
name: magrathea-php2
description: How to correctly generate code using the MagratheaPHP2 framework. Read before writing any PHP code for a project that uses this framework.
---
# skills.MD — How to Use MagratheaPHP2 in a Project
This file teaches an AI assistant how to correctly generate code using the MagratheaPHP2 framework. It is a practical, task-oriented cookbook. Read it before writing any PHP code for a project that uses this framework.
Reference files:
- `instructions.MD` — project structure and rules
- `documentation/index.md` — full API reference
---
## Table of Contents
1. [Project Bootstrap](#1-project-bootstrap)
2. [Configuration](#2-configuration)
3. [Creating a Model](#3-creating-a-model)
4. [Creating a Control](#4-creating-a-control)
5. [Building Queries](#5-building-queries)
6. [Creating an API Class](#6-creating-an-api-class)
7. [Creating an API Controller](#7-creating-an-api-controller)
8. [JWT Authentication](#8-jwt-authentication)
9. [Caching](#9-caching)
10. [Logging & Debugging](#10-logging--debugging)
11. [Sending Email](#11-sending-email)
12. [Admin Panel — Entry Point](#12-admin-panel--entry-point)
13. [Admin Panel — Admin Class](#13-admin-panel--admin-class)
14. [Admin Panel — CRUD Features](#14-admin-panel--crud-features)
15. [Error Handling](#15-error-handling)
16. [Testing](#16-testing)
17. [Complete Application Skeleton](#17-complete-application-skeleton)
18. [Checklist Before Delivering Code](#18-checklist-before-delivering-code)
---
## 1. Project Bootstrap
**Every entry point starts the same way**, and in real projects this bootstrap lives in one shared file — conventionally `app/_inc.php` — that every entry point (`public/index.php`, `public/admin.php`) requires. Never skip any step.
Real projects organize domain code as **features**, not flat `models/`/`controls`/ folders: one folder per table/domain under `app/features/<Name>/`, added via `->AddFeature(...)` (see §3–4 for what goes inside). Plain `->AddCodeFolder(...)` is still used for non-feature folders like `admin`, `api`, `shared`.
```php
<?php
// app/_inc.php
require __DIR__ . "/../vendor/autoload.php";
error_reporting(E_ALL);
ini_set("display_errors", "1");
try {
Magrathea2\MagratheaPHP::Instance()
->MinVersion("2.1.19") // optional, pins a minimum framework version
->AppPath(realpath(dirname(__FILE__))) // this folder (app/) becomes appRoot
->AddCodeFolder(
"admin",
"api",
"api/Authentication",
"api/Controls",
"shared",
)
->AddFeature( // adds features/<Name> + features/<Name>/Base for each
"Article", "Author",
)
->Dev() // or ->Prod() in production
->Load(); // reads config/magrathea.conf, relative to appRoot's parent
} catch (Exception $ex) {
\Magrathea2\p_r($ex);
}
```
Entry points then just require it:
```php
<?php
// public/index.php
require __DIR__ . "/../app/_inc.php";
// ... build and Run() the API class ...
```
**Rules:**
- `AppPath()` must be called before `Load()`; `config/magrathea.conf` is expected one level **above** `AppPath()` (i.e. sibling of `app/`, not inside it)
- `AddCodeFolder()` paths are relative to `AppPath`
- `AddFeature("Article", ...)` adds `<appRoot>/features/Article` and `<appRoot>/features/Article/Base` to the autoloader — see §3 for what lives in each
- Use `->Dev()` locally, `->Prod()` on servers
- Call `->StartSession()` before any session use (admin panel, etc.)
- `->Connect()` is optional in `_inc.php` — many projects let each request connect lazily; call it explicitly if you need the DB immediately
---
## 2. Configuration
**Config file location:** `<appRoot>/config/magrathea.conf`
Sections are named by **environment**, not by topic. `[general]` holds cross-environment settings, including `use_environment` (which section is active by default — usually overridden by `->Dev()` / `->Prod()`). Each environment section (`[dev]`, `[production]`, …) repeats the full flat set of keys it needs — there is no `[section:env]` override/inheritance syntax.
### Writing a config file
```ini
[general]
use_environment = "default"
time_zone = "America/Sao_Paulo"
[dev]
db_host = "localhost"
db_name = "my_db"
db_user = "root"
db_pass = "secret"
db_port = "3306"
site_path = "/var/www/html"
logs_path = "../logs"
cache_path = "../cache"
timezone = "America/Sao_Paulo"
server_url = "http://localhost:8080"
jwt_key = "a-very-long-random-string-here"
[production]
db_host = "db.prod.server"
db_name = "my_db"
db_user = "app_user"
db_pass = "$=DB_PASSWORD"
db_port = "3306"
site_path = "/var/www/html"
logs_path = "../logs"
cache_path = "../cache"
timezone = "America/Sao_Paulo"
server_url = "https://myapp.example.com"
jwt_key = "$=JWT_SECRET"
```
Keys are quoted strings; indentation with a tab under each section header is the convention seen in real configs (not required by the parser, but keep it for consistency).
### Reading config in code
```php
use Magrathea2\Config;
// Read a single key from the active environment section
$host = Config::Instance()->GetConfig("db_host");
// Read the full active-environment section
$env = Config::Instance()->GetConfigSection();
echo $env["db_host"];
```
**Never hardcode credentials.** Use `$=ENV_VAR_NAME` for secrets.
---
## 3. Creating a Model
A model maps to one database table, and lives in its own **feature** folder (added via `->AddFeature("Article", ...)` in `_inc.php`, see §1). This is the convention observed across real production Magrathea2 projects — prefer it over a flat `models/` folder.
Each feature is **split into a generated `Base` class and a concrete class**:
- `app/features/Article/Base/ArticleBase.php` — holds `$dbTable`, `$dbPk`, `$dbValues`, and FK **relations**. Marked as generated ("changes can be overwritten through the admin") because Magrathea's admin tooling can regenerate it from the DB schema — but hand-writing it in this exact shape is normal when that tooling isn't in play.
- `app/features/Article/Article.php` — the concrete class, `extends Base\ArticleBase`, empty unless you add real business-logic methods.
```php
<?php
// app/features/Article/Base/ArticleBase.php
## FILE GENERATED BY MAGRATHEA.
## This file was automatically generated and changes can be overwritten through the admin
namespace App\Models\Base;
use Magrathea2\iMagratheaModel;
use Magrathea2\MagratheaModel;
class ArticleBase extends MagratheaModel implements iMagratheaModel {
public $id, $title, $body, $author_id, $published, $views;
public $created_at, $updated_at;
protected $autoload = null;
public function __construct($id = 0) {
$this->MagratheaStart();
if (!empty($id)) {
$pk = $this->dbPk;
$this->$pk = $id;
$this->GetById($id);
}
}
public function MagratheaStart() {
$this->dbTable = "articles";
$this->dbPk = "id";
$this->dbValues["id"] = "int";
$this->dbValues["title"] = "string";
$this->dbValues["body"] = "text";
$this->dbValues["author_id"] = "int";
$this->dbValues["published"] = "boolean";
$this->dbValues["views"] = "int";
$this->dbValues["created_at"] = "datetime";
$this->dbValues["updated_at"] = "datetime";
// FK relation example — lazy-loaded getter/setter pair
$this->relations["properties"]["Author"] = null;
$this->relations["methods"]["Author"] = "GetAuthor";
$this->relations["lazyload"]["Author"] = "true";
$this->relations["external"]["author_id"] = "\App\Author\Author";
}
public function GetControl() {
return new \App\Controls\Base\ArticleControlBase();
}
// >>> relations:
public function GetAuthor() {
if ($this->relations["properties"]["Author"] != null) return $this->relations["properties"]["Author"];
$this->relations["properties"]["Author"] = new \App\Author\Author($this->author_id);
return $this->relations["properties"]["Author"];
}
public function SetAuthor($author) {
$this->relations["properties"]["Author"] = $author;
$this->author_id = $author->GetID();
return $this;
}
}
```
```php
<?php
// app/features/Article/Article.php
namespace App\Models;
class Article extends \App\Models\Base\ArticleBase {
public function __construct($id = 0) {
parent::__construct($id);
}
// real business-logic methods go here, e.g.:
public function IsPublished(): bool {
return (bool) $this->published;
}
}
```
**Important:** declare every `$dbValues` field as a public property on the Base class (untyped, comma-declared as above, matching the generator's style). Do NOT use `#[\AllowDynamicProperties]`.
For simple internal tools without the feature/Base convention, a flat single-class model (`$dbTable`/`$dbPk`/`$dbValues` directly in one class extending `MagratheaModel`) also works — but match whatever convention the rest of the project already uses.
### Supported field types
| Type | PHP equivalent |
|------|---------------|
| `int` | integer |
| `boolean` | bool (stored as TINYINT 0/1) |
| `string` | string (VARCHAR etc.) |
| `text` | string (TEXT column) |
| `float` | float (DECIMAL/FLOAT) |
| `datetime` | string in `Y-m-d H:i:s` format |
| `uuid` | string (CHAR(36)), auto-generated UUIDv7 on insert if unset |
`uuid` behaves the same way `created_at`/`updated_at` already do: declare `$this->dbValues["uuid"] = "uuid";` and the framework fills it in on `Insert()` (via `Uuid::V7()`) whenever the property is left empty. An explicitly pre-set value (e.g. from a fixture or migration) is respected and never overwritten. Nothing changes on `Update()` — UUIDs are immutable once assigned. Requires MagratheaPHP2 `2.1.30`+.
### Using a model
```php
// Create and insert
$article = new Article();
$article->title = "Hello World";
$article->body = "My first article.";
$article->published = true;
$article->created_at = now();
$id = $article->Save(); // returns new ID (or ->Insert() explicitly)
// Load by PK
$article = new Article(42);
echo $article->title;
// Update
$article->title = "Updated Title";
$article->Save(); // detects existing PK → UPDATE
// Delete
$article->Delete();
// Serialize for API response — use ToArray() for flat map, ToJson() for full envelope
return $article->ToArray();
```
---
## 4. Creating a Control
Every model gets a companion Control class for static data access — same feature-folder Base split as the model.
```php
<?php
// app/features/Article/Base/ArticleControlBase.php
## FILE GENERATED BY MAGRATHEA.
## This file was automatically generated and changes can be overwritten through the admin
namespace App\Controls\Base;
use Magrathea2\MagratheaModelControl;
class ArticleControlBase extends MagratheaModelControl {
protected static $modelNamespace = "App\\Models\\";
protected static $modelName = "Article";
protected static $dbTable = "articles";
}
```
```php
<?php
// app/features/Article/ArticleControl.php
namespace App\Controls;
use Magrathea2\DB\Query;
class ArticleControl extends \App\Controls\Base\ArticleControlBase {
// real query/business-logic methods go here, e.g.:
public function GetPublished() {
return $this->GetWhere(["published" => 1]);
}
}
```
### Using a control
```php
use App\Controls\ArticleControl;
// All records
$all = ArticleControl::GetAll();
// Filtered
$published = ArticleControl::GetWhere(["published" => 1]);
// First match
$article = ArticleControl::GetRowWhere(["id" => 42]);
// Custom SQL
$recent = ArticleControl::RunQuery(
"SELECT * FROM articles WHERE created_at > '2024-01-01' ORDER BY created_at DESC LIMIT 5"
);
// Count
$total = ArticleControl::QueryOne("SELECT COUNT(*) FROM articles WHERE published = 1");
// Paginated (with total — costs an extra COUNT(*) query)
$total = 0;
$query = \Magrathea2\DB\Query::Select()
->Obj(new \App\Models\Article())
->Where(["published" => 1])
->Order("created_at DESC");
$page = ArticleControl::RunPagination($query, $total, page: 0, limit: 10);
// Paginated for an API response — no COUNT(*) query, has_more computed via limit+1 trick.
// Return the MagratheaPagination directly from a controller action; see §7.
$query = \Magrathea2\DB\Query::Select()
->Obj(new \App\Models\Article())
->Where(["published" => 1])
->Order("created_at DESC");
$pagination = ArticleControl::GetPagination($query, page: 0, limit: 10); // MagratheaPagination
```
---
## 5. Building Queries
Use the Query Builder for anything beyond simple `GetWhere` calls.
```php
use Magrathea2\DB\Query;
use Magrathea2\DB\Database;
// Basic SELECT
$sql = Query::Select()
->Table("articles")
->Where(["published" => 1])
->Order("created_at DESC")
->Limit(10)
->Page(0) // page * limit = OFFSET
->SQL();
// SELECT with model (auto-fills table and fields)
$sql = Query::Select()
->Obj(\App\Models\Article::class)
->Where(["published" => 1])
->SQL();
// JOIN
$sql = Query::Select()
->Obj(\App\Models\Article::class)
->SelectExtra("u.name AS author_name")
->Inner("users u", "u.id = articles.author_id")
->Where(["articles.published" => 1])
->SQL();
// INSERT
$sql = Query::Insert()
->Table("articles")
->Values(["title" => "New Post", "published" => 0, "created_at" => now()])
->SQL();
// UPDATE
$sql = Query::Update()
->Table("articles")
->SetArray(["title" => "New Title", "updated_at" => now()])
->Where("id = 42")
->SQL();
// DELETE
$sql = Query::Delete()
->Table("articles")
->Where("id = 42")
->SQL();
// Execute
$rows = Database::Instance()->QueryAll($sql);
$one = Database::Instance()->QueryRow($sql);
$val = Database::Instance()->QueryOne($sql);
```
**Always use `Query::Clean()` or `PrepareAndExecute()` for user input:**
```php
$safe = Query::Clean($_GET["search"]);
$sql = "SELECT * FROM articles WHERE title LIKE '%$safe%'";
// Better: prepared statement
$result = Database::Instance()->PrepareAndExecute(
"SELECT * FROM articles WHERE title LIKE ?",
["s"],
["%{$_GET['search']}%"]
);
```
---
## 6. Creating an API Class
The preferred pattern is to encapsulate all route definitions in a class that extends `MagratheaApi`. This makes the API reusable — both the HTTP entry point and the Admin ApiExplorer instantiate the same class.
```php
<?php
namespace App;
use Magrathea2\MagratheaApi;
use App\Api\AuthControl;
use App\Api\ArticleApiControl;
class MyAppApi extends MagratheaApi {
public function __construct() {
$this->Initialize();
}
public function Initialize() {
$this->AllowAll();
$this->SetAddress("/api/v1");
$this->HealthCheck(true); // includes "database": "ok"|"fail" in response
$this->SetAuth();
$this->Articles();
}
private function SetAuth() {
$auth = new AuthControl();
$this->BaseAuthorization($auth, "ValidateToken");
$this->Add("POST", "auth/login", $auth, "Login");
$this->Add("GET", "me", $auth, "Me", true);
}
private function Articles() {
$api = new ArticleApiControl();
$this->Add("GET", "articles", $api, "List", true);
$this->Add("GET", "articles/:id", $api, "Read", true);
$this->Add("POST", "articles", $api, "Create", true);
$this->Add("PUT", "articles/:id", $api, "Update", true);
$this->Add("DELETE", "articles/:id", $api, "Delete", true);
}
}
```
**HTTP entry point** (`public/index.php`) — thin wrapper that runs the API class:
```php
<?php
// shared bootstrap (bootstrap.php) sets up MagratheaPHP
$api = require __DIR__ . '/api.php';
$api->Run();
```
**Shared setup file** (`public/api.php`) — returns the configured instance, importable by both entry point and admin:
```php
<?php
require_once __DIR__ . '/../vendor/autoload.php';
// ... MagratheaPHP bootstrap ...
return new \App\MyAppApi();
```
**Key rules:**
- Group routes into private methods by domain (Auth, Articles, etc.)
- `Run()` is called only in the HTTP entry point, never inside the class
- `AllowAll()` is fine for development; use `Allow([...])` in production
- Route params use `:name` syntax — they arrive as `$params["name"]` in the controller
---
## 7. Creating an API Controller
```php
<?php
namespace App\Api;
use Magrathea2\MagratheaApiControl;
use Magrathea2\MagratheaPagination;
use Magrathea2\Exceptions\MagratheaApiException;
use Magrathea2\DB\Query;
use App\Controls\ArticleControl;
use App\Models\Article;
class ArticleApiControl extends MagratheaApiControl {
// GET /articles
public function List(): array {
$articles = ArticleControl::GetWhere(["published" => 1]);
return array_map(fn($a) => $a->ToArray(), $articles);
}
// GET /articles/paginated?page=0 — returning a MagratheaPagination makes
// MagratheaApi::ReturnSuccess() build {success, data, page, count, has_more} automatically.
public function ListPaginated(array $params = []): MagratheaPagination {
$query = Query::Select()
->Obj(new Article())
->Where(["published" => 1])
->Order("created_at DESC");
return ArticleControl::GetPagination($query, page: (int)($params["page"] ?? 0), limit: 20);
}
// GET /articles/:id
public function Read(array $params = []): array {
$article = ArticleControl::GetRowWhere(["id" => $params["id"]]);
if (!$article) {
throw new MagratheaApiException("Article not found", 404);
}
return $article->ToArray();
}
// POST /articles
public function Create(array $data = []): array {
$post = $this->GetPost(); // reads JSON or form body
$article = new Article();
$article->title = $post["title"] ?? "";
$article->body = $post["body"] ?? "";
$article->author_id = $this->GetUserId(); // from JWT
$article->published = false;
$article->created_at = now();
$article->Save();
return $article->ToArray();
}
// PUT /articles/:id
public function Update(array $params = []): array {
$article = ArticleControl::GetRowWhere(["id" => $params["id"]]);
if (!$article) {
throw new MagratheaApiException("Article not found", 404);
}
$put = $this->GetPut();
if (isset($put["title"])) $article->title = $put["title"];
if (isset($put["body"])) $article->body = $put["body"];
$article->updated_at = now();
$article->Save();
return $article->ToArray();
}
// DELETE /articles/:id
public function Delete(array $params = []): bool {
$article = ArticleControl::GetRowWhere(["id" => $params["id"]]);
if (!$article) {
throw new MagratheaApiException("Article not found", 404);
}
return $article->Delete();
}
}
```
**Controller rules:**
- Always throw `MagratheaApiException` for expected errors (404, 400, 403) — the router converts it to JSON automatically
- Use `$this->GetPost()` for POST body, `$this->GetPut()` for PUT body
- Use `$this->GetUserId()` to read the user ID from the decoded JWT token
- Return `->ToArray()` (flat field map) for API responses — never `->ToJson()` (which wraps fields in an outer envelope)
- Never `echo` or `die()` inside controllers
---
## 8. JWT Authentication
### Step 1: Auth controller
```php
<?php
namespace App\Api;
use Magrathea2\MagratheaApiControl;
use Magrathea2\Exceptions\MagratheaApiException;
use Magrathea2\Config;
use App\Controls\UserControl;
class AuthControl extends MagratheaApiControl {
// Override to use config-driven secret
public function GetSecret(): string {
return Config::Instance()->GetConfig("jwt/secret");
}
// POST /auth/login
public function Login(): array {
$post = $this->GetPost();
$user = UserControl::GetRowWhere(["email" => $post["email"] ?? ""]);
if (!$user || !password_verify($post["password"] ?? "", $user->password_hash)) {
throw new MagratheaApiException("Invalid credentials", 401);
}
$token = $this->jwtEncode([
"user_id" => $user->id,
"email" => $user->email,
"role" => $user->role,
"exp" => time() + 86400, // 24 hours
]);
return ["token" => $token, "user" => $user->ToArray()];
}
// Used as base authorization for all protected routes
public function ValidateToken(): bool {
$token = $this->GetAuthorizationToken(); // reads Bearer token
$payload = $this->GetTokenInfo($token); // decodes + verifies
if (!$payload || !isset($payload->user_id)) {
throw new MagratheaApiException("Unauthorized", 0, null, true);
}
$this->userInfo = $payload; // store for downstream use
return true;
}
}
```
### Step 2: Access user in other controllers
```php
public function Create(array $data = []): array {
$userId = $this->GetUserId(); // int|null from token
$info = $this->GetUserInfo(); // full decoded payload object
$role = $info->role ?? "guest";
// ...
}
```
**JWT rules:**
- Always set `"exp"` in the payload
- Never store passwords or sensitive PII in the token
- Always override `GetSecret()` to use a config value, not a hardcoded string
- `GetAuthorizationToken()` throws if the header is missing — call it inside `try/catch` or rely on the route's auth parameter
---
## 9. Caching
`Cache($name, $data=null)` does **not** take the response payload as its second argument. `$data` is an optional key *suffix/discriminator* — it gets appended to `$name` (as `"{$name}-{$data}"`) to build the cache file's handle, e.g. to key the cache by an id or page number. Never pass a JSON blob or the computed result as `$data`.
Calling `$this->Cache($name, $data)` only *peeks*: if a cache file for that key already exists, it outputs it and kills execution (`die`) right there. It never writes anything itself. The actual write-to-disk happens automatically later, when the framework serializes the controller's return value to JSON — no second manual call is needed or correct.
### In a controller (recommended pattern)
```php
public function List(): array {
// Serve from cache if available (outputs and exits on hit).
// Saving happens automatically when the response is emitted.
$this->Cache("articles_published");
$articles = ArticleControl::GetWhere(["published" => 1]);
return array_map(fn($a) => $a->ToArray(), $articles);
}
public function View(array $params = []): array {
// $data suffix keys the cache per-id
$this->Cache("article", $params["id"]);
$article = new ArticleControl($params["id"]);
return $article->ToArray();
}
public function Create(array $data = []): array {
// ... create ...
// Invalidate related cache (same $name/$data used to build the key)
$this->CacheClear("articles_published");
return $article->ToArray();
}
```
### Invalidate by pattern (when many related keys exist)
```php
$this->CacheClearPattern("articles_*");
```
### Cache key naming convention
Always include parameters that affect the result. Prefer the `$data` suffix param for a single discriminator (id, page); bake the rest into `$name`:
```php
$this->Cache("articles_cat_{$categoryId}", $page);
$this->Cache("user_profile", $userId);
"products_active_sort_price"
```
---
## 10. Logging & Debugging
### Logging (production)
```php
use Magrathea2\Logger;
Logger::Instance()->Log("Order #$orderId processed by user #$userId");
try {
// something risky
} catch (\Exception $e) {
Logger::Instance()->LogError($e);
}
Logger::Instance()->SetLogFile("payments")->Log("Payment success: $amount");
```
### Debugging (development only)
```php
use Magrathea2\Debugger;
Debugger::Instance()->Info("Cache miss for: articles_published");
Debugger::Instance()->Add(["user_id" => $id, "role" => $role]);
Debugger::Instance()->Show();
```
---
## 11. Sending Email
### Native mail()
```php
use Magrathea2\MagratheaMail;
$mail = new MagratheaMail();
$mail->SetTo($user->email)
->SetFrom("noreply@myapp.com")
->SetSubject("Welcome!")
->SetHTMLMessage("<h1>Hi!</h1>")
->SetTXTMessage("Hi!")
->Send();
```
### SMTP
```php
use Magrathea2\MagratheaMailSMTP;
$mail = new MagratheaMailSMTP(); // uses [mail] section from config
$mail->SetTo($user->email)->SetSubject("Reset")->SetHTMLMessage($html)->Send();
```
---
## 12. Admin Panel — Entry Point
The admin entry point bootstraps Magrathea (including `StartSession()`), then hands off to `AdminManager`.
```php
<?php
// public/admin.php
use Magrathea2\Admin\AdminManager;
include("bootstrap.php"); // shared MagratheaPHP setup (no Run())
include("../admin/MyAppAdmin.php"); // the Admin class
try {
AdminManager::Instance()->Start(new \App\MyAppAdmin());
} catch (Exception $ex) {
\Magrathea2\p_r($ex);
}
```
**Rules:**
- `bootstrap.php` must call `->StartSession()` for admin auth to work
- `AdminManager::Instance()->Start()` handles auth, rendering, and routing — it does not return
- Never call `Run()` on the API inside an admin entry point
---
## 13. Admin Panel — Admin Class
Extend `\Magrathea2\Admin\Admin` and implement `\Magrathea2\Admin\iAdmin`.
```php
<?php
namespace App;
use Magrathea2\Admin\AdminMenu;
use Magrathea2\Admin\Features\ApiExplorer\ApiExplorer;
use Magrathea2\Admin\Features\AppConfig\AdminFeatureAppConfig;
use App\Admin\ArticleAdmin;
class MyAppAdmin extends \Magrathea2\Admin\Admin implements \Magrathea2\Admin\iAdmin {
private $features = [];
private $apiFeature;
public function Initialize() {
$this->SetTitle("My App Admin");
$this->SetPrimaryColor("#5a2672");
$this->SetAdminLogo(__DIR__ . "/logo.svg"); // absolute path
}
public function Auth($user): bool {
return parent::Auth($user); // uses default Magrathea admin auth
}
public function SetFeatures() {
parent::SetFeatures();
$this->LoadConfig();
$this->LoadFeatures();
$this->LoadApi();
}
public function LoadApi() {
$this->apiFeature = new ApiExplorer();
$this->apiFeature->SetApi(new MyAppApi()); // instantiate the API class
$this->AddFeature($this->apiFeature);
}
public function LoadConfig() {
$this->features["app-config"] = new AdminFeatureAppConfig(true);
$this->features["app-config"]->featureId = "AppConfig";
$this->features["app-config"]->featureName = "Settings";
$this->AddFeature($this->features["app-config"]);
}
public function LoadFeatures() {
$this->AddCrudFeature(new ArticleAdmin());
// add more CRUD features here
}
public function BuildMenu(): AdminMenu {
$menu = new AdminMenu();
$menu->Add($this->features["app-config"]->GetMenuItem());
$menu->Add($menu->CreateTitle("Api"))
->Add($this->apiFeature->GetMenuItem());
$this->AddFeaturesMenu($menu); // auto-adds all CRUD features
$menu->Add(["title" => "Magrathea", "type" => "main"]);
$this->AddMagratheaMenu($menu);
$menu->Add($menu->GetLogoutMenuItem());
return $menu;
}
}
```
**Key methods:**
- `Initialize()` — title, primary color, logo path (called once on load)
- `Auth($user)` — return `parent::Auth($user)` for default Magrathea auth
- `SetFeatures()` — orchestrates LoadConfig / LoadFeatures / LoadApi; always call `parent::SetFeatures()` first
- `LoadApi()` — creates `ApiExplorer`, calls `SetApi(new YourApiClass())`, adds feature
- `LoadFeatures()` — calls `AddCrudFeature()` for each CRUD admin class
- `BuildMenu()` — constructs the sidebar; use `CreateTitle()` for section headers, `AddFeaturesMenu()` to auto-include CRUD items, always end with `GetLogoutMenuItem()`
- `AddFeature()` vs `AddCrudFeature()` — use `AddCrudFeature` for `AdminCrudObject` subclasses, `AddFeature` for everything else
---
## 14. Admin Panel — CRUD Features
Each model gets a companion CRUD admin class extending `AdminCrudObject`.
```php
<?php
namespace App\Admin;
use Magrathea2\Admin\Features\CrudObject\AdminCrudObject;
class ArticleAdmin extends AdminCrudObject {
protected $modelName = "Article";
protected $modelNamespace = "App\\Models\\";
protected $controlName = "ArticleControl";
protected $controlNamespace = "App\\Controls\\";
protected $label = "Articles";
protected $icon = "newspaper"; // Bootstrap Icons name
}
```
**The admin class folder must be registered with `AddCodeFolder()` in bootstrap.**
### Field visibility / permissions
By default `AdminCrudObject` allows full CRUD. Override to restrict:
```php
class ArticleAdmin extends AdminCrudObject {
// ... properties ...
public function CanCreate(): bool { return false; } // read-only list
public function CanEdit(): bool { return false; }
public function CanDelete(): bool { return true; }
}
```
### Bootstrap Icons reference
Common values for `$icon`: `person`, `file-earmark-text`, `card-text`, `pencil`, `trash`, `search`, `newspaper`, `gear`, `box-arrow-right`.
---
## 15. Error Handling
### In API controllers — always use MagratheaApiException
```php
use Magrathea2\Exceptions\MagratheaApiException;
throw new MagratheaApiException("Email is required", 400);
throw new MagratheaApiException("Unauthorized", 401);
throw new MagratheaApiException("You don't have permission", 403);
throw new MagratheaApiException("Resource not found", 404);
throw (new MagratheaApiException("Validation failed", 422))
->SetData(["errors" => $validationErrors]);
try {
// some operation
} catch (\Exception $e) {
throw MagratheaApiException::FromException($e, 500);
}
```
### Global exception handler (optional, add to entry point)
```php
set_exception_handler(function (\Throwable $e) {
Logger::Instance()->LogError($e);
http_response_code(500);
header("Content-Type: application/json");
echo json_encode(["error" => "Internal Server Error"]);
exit;
});
```
---
## 16. Testing
### PHPUnit bootstrap (phpunit.xml)
```xml
<phpunit bootstrap="vendor/autoload.php">
<testsuites>
<testsuite name="App">
<directory>tests/</directory>
</testsuite>
</testsuites>
</phpunit>
```
### Mocking for unit tests
```php
use PHPUnit\Framework\TestCase;
use Magrathea2\DB\Database;
use Magrathea2\Config;
class MyTest extends TestCase {
protected function setUp(): void {
Database::Instance()->Mock();
Config::Instance()->SetConfig([
"database" => ["host" => "localhost", "database" => "test"],
"jwt" => ["secret" => "test-secret"],
]);
}
}
```
---
## 17. Complete Application Skeleton
```
my-app/
├── composer.json
├── config/
│ └── magrathea.conf
├── models/
│ └── Article.php
├── controls/
│ └── ArticleControl.php
├── api/
│ ├── AuthControl.php
│ └── ArticleApiControl.php
├── admin/
│ ├── MyAppAdmin.php ← Admin class
│ ├── ArticleAdmin.php ← CRUD feature
│ └── logo.svg
├── public/
│ ├── bootstrap.php ← shared MagratheaPHP setup (returns nothing)
│ ├── api.php ← returns new MyAppApi() (importable)
│ ├── index.php ← require api.php → Run()
│ └── admin.php ← AdminManager::Start(new MyAppAdmin())
├── MyAppApi.php ← extends MagratheaApi
├── cache/
├── logs/
└── vendor/
```
**`public/bootstrap.php`** — shared setup, no output:
```php
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use Magrathea2\MagratheaPHP;
MagratheaPHP::LoadVendor();
MagratheaPHP::Instance()
->AppPath(__DIR__ . '/..')
->AddCodeFolder("models", "controls", "api", "admin")
->Prod()->Load()->Connect()->StartSession();
```
**`public/api.php`** — importable, returns the configured API:
```php
<?php
require_once __DIR__ . "/bootstrap.php";
return new \App\MyAppApi();
```
**`public/index.php`** — HTTP entry point:
```php
<?php
$api = require __DIR__ . "/api.php";
$api->Run();
```
**`public/admin.php`** — admin entry point:
```php
<?php
use Magrathea2\Admin\AdminManager;
require __DIR__ . "/bootstrap.php";
try {
AdminManager::Instance()->Start(new \App\MyAppAdmin());
} catch (Exception $ex) {
\Magrathea2\p_r($ex);
}
```
---
## 18. Checklist Before Delivering Code
### Bootstrap
- [ ] `MagratheaPHP::LoadVendor()` is called first
- [ ] `AppPath()` is set with `__DIR__` or an absolute path
- [ ] All class folders registered with `AddCodeFolder()` (including `admin/` for admin panels)
- [ ] `->Load()->Connect()` is called before any DB operation
- [ ] `->StartSession()` called when admin panel is used
- [ ] Mode is `->Dev()` for local, `->Prod()` for server
### Models
- [ ] `$dbTable` is set
- [ ] `$dbValues` includes all columns with correct types
- [ ] `$dbPk` is set if primary key is not `"id"`
- [ ] Each `$dbValues` field has an explicit typed public property declaration
- [ ] No `#[\AllowDynamicProperties]` attribute used
- [ ] If the project uses UUIDs as external ids, the field is declared as `"uuid"` type, not hand-rolled
### Controls
- [ ] `$modelName`, `$modelNamespace`, `$dbTable` are all set
- [ ] Namespace matches the actual file location
### API Class
- [ ] Extends `MagratheaApi`
- [ ] Constructor calls `Initialize()`
- [ ] Routes grouped into domain methods
- [ ] `Run()` is NOT called inside the class — only in the HTTP entry point
### API Controllers
- [ ] Every controller extends `MagratheaApiControl`
- [ ] All error cases throw `MagratheaApiException`, not generic exceptions
- [ ] User input never directly interpolated into SQL
- [ ] `GetPost()` / `GetPut()` used (not `$_POST` directly)
- [ ] Returns `->ToArray()` not `->ToJson()` for API responses
### Admin Class
- [ ] Extends `\Magrathea2\Admin\Admin` and implements `\Magrathea2\Admin\iAdmin`
- [ ] `SetFeatures()` calls `parent::SetFeatures()` first
- [ ] `LoadApi()` uses `SetApi(new YourApiClass())` — not `require`
- [ ] `BuildMenu()` ends with `GetLogoutMenuItem()`
- [ ] `AddCrudFeature()` used for `AdminCrudObject` subclasses
### Admin CRUD Features
- [ ] Extends `AdminCrudObject`
- [ ] `$modelName`, `$modelNamespace`, `$controlName`, `$controlNamespace` all set
- [ ] `$label` and `$icon` set for UI
### Config
- [ ] Secrets use `$=ENV_VAR` — never hardcoded
- [ ] Config file at `config/magrathea.conf` relative to `AppPath`
### General
- [ ] `now()` used for datetime fields
- [ ] Cache invalidated when data is mutated
- [ ] Singletons accessed via `::Instance()`, never `new ClassName()`
# Instructions for AI Assistants — MagratheaPHP2
This file is a guide for any AI assistant (Claude, GPT, Gemini, etc.) working on this codebase. Read it before doing any task. It explains the project structure, conventions, key design decisions, and how to approach common tasks.
---
## What This Project Is
**MagratheaPHP2** is a full PHP framework primarily designed for building RESTful APIs. It also ships with an ORM, a query builder, a file-based cache, a logging system, an email system, CSS/JS compression, and an admin panel.
- **Namespace root:** `Magrathea2`
- **Source code:** `src/`
- **Documentation:** `documentation/`
- **Config format:** INI files (`config/magrathea.conf`)
- **PHP version:** 8.0+
- **Database:** MySQL / MariaDB via MySQLi
---
## Source Code Map
```
src/
├── MagratheaPHP.php ← Main bootstrap/entry point (start here)
├── Config.php ← INI-based config
├── ConfigApp.php ← DB-stored key-value config
├── ConfigFile.php ← Config file I/O
├── Singleton.php ← Base singleton pattern
├── MagratheaHelper.php ← Static utility functions
├── MagratheaModel.php ← ORM base model (abstract)
├── MagratheaModelControl.php ← ORM static query interface (abstract)
├── MagratheaApi.php ← RESTful API router
├── MagratheaApiControl.php ← API controller base
├── MagratheaCache.php ← File-based response cache
├── MagratheaMail.php ← Email (native mail())
├── MagratheaMailSMTP.php ← Email (SMTP via PHPMailer)
├── Authentication.php ← JWT token generation
├── Logger.php ← File-based logging
├── Debugger.php ← Debug mode manager
├── _Functions.php ← Global procedural helpers + autoloader
├── _FunctionsDebug.php ← Debug helpers
├── _EnumTrait.php ← Enum trait
├── DB/
│ ├── Database.php ← MySQLi wrapper (Singleton)
│ ├── Query.php ← Fluent SELECT query builder
│ ├── QueryInsert.php ← INSERT query builder
│ ├── QueryUpdate.php ← UPDATE query builder
│ ├── QueryDelete.php ← DELETE query builder
│ ├── QueryHelper.php ← Query utilities
│ └── DatabaseSimulate.php ← Mock database for tests
├── Exceptions/
│ ├── MagratheaException.php ← Base exception
│ ├── MagratheaApiException.php ← API exceptions (carries HTTP status)
│ ├── MagratheaDBException.php ← DB exceptions
│ ├── MagratheaConfigException.php
│ └── MagratheaModelException.php
├── Compressors/
│ ├── MagratheaCompressor.php ← Abstract base
│ ├── CssCompressor.php ← CSS + SCSS → minified CSS
│ └── JavascriptCompressor.php ← JS → minified JS
├── Errors/
│ └── ErrorManager.php
├── Tests/
│ ├── TestsManager.php
│ ├── TestsHelper.php
│ └── phpUnitBootstrap.php
├── Bootstrap/ ← Setup wizard
└── Admin/ ← Admin panel system
├── Admin.php ← Admin config object
├── AdminManager.php ← Admin runtime (Singleton)
├── AdminFeature.php ← Feature base class
├── AdminMenu.php ← Menu builder
├── AdminForm.php ← Form helpers
├── AdminElements.php ← UI element helpers
├── AdminDatabase.php
├── AdminUrls.php
├── AdminUsers.php
├── ObjectManager.php
├── CodeCreator.php
├── CodeManager.php
├── iAdmin.php
├── Install.php
├── Start.php
└── Features/
├── AppConfig/ ← Manage DB-stored config in admin
├── Cache/ ← Cache management
├── CrudObject/ ← Auto-generated CRUD UI
├── FileEditor/ ← Server-side file editor
├── User/ ← Admin user management
├── UserLogs/ ← Admin action log
└── ApiExplorer/ ← API endpoint browser
```
---
## Key Concepts
### 1. Singleton Services
All manager/service classes are singletons. Always access them via `ClassName::Instance()`, never `new ClassName()`:
```php
Database::Instance()
Config::Instance()
Logger::Instance()
Debugger::Instance()
MagratheaCache::Instance()
AdminManager::Instance()
```
### 2. Model + Control Pair
Every database entity has two classes:
- **Model** (`extends MagratheaModel`): instance-level, handles a single row, provides CRUD operations
- **Control** (`extends MagratheaModelControl`): static-only, handles queries returning collections
### 3. Query Builder Doesn't Execute
`Query` only builds SQL strings. Execution is done by `Database::Instance()->QueryAll($query->SQL())` or through the Control methods.
### 4. API Structure
- `MagratheaApi` — the router/runner. Register routes with `->Add()` or `->Crud()`, then call `->Run()`.
- `MagratheaApiControl` — base for controllers. Override `List()`, `Read()`, `Create()`, `Update()`, `Delete()`.
- JWT auth is built-in: use `GetAuthorizationToken()`, `GetTokenInfo()`, `jwtEncode()`, `jwtDecode()`.
### 5. Configuration
Config files use INI format. Multi-environment: `[section:environment]` overrides `[section]` when that environment is active. Environment variable interpolation: `password = $=MY_ENV_VAR`.
---
## Common Tasks
### Adding a new database entity
1. Create `models/MyThing.php` extending `MagratheaModel`
2. Define `$dbTable`, `$dbPk`, `$dbValues`
3. Create `controls/MyThingControl.php` extending `MagratheaModelControl`
4. Set `$modelName`, `$modelNamespace`, `$dbTable`
### Adding a new API endpoint
1. Create/extend `api/MyThingApiControl.php` extending `MagratheaApiControl`
2. Override methods: `List()`, `Read()`, `Create()`, `Update()`, `Delete()`
3. Register in your entry point with `$api->Add(...)` or `$api->Crud(...)`
### Adding admin CRUD
1. Create a class extending `AdminCrudObject` in `admin/`
2. Set `$modelName`, `$modelNamespace`, `$controlName`, `$controlNamespace`, `$label`
3. Register via `$admin->AddFeaturesArray([new MyThingAdminFeature()])`
### Debugging queries
```php
MagratheaPHP::Instance()->Debug(); // enables query logging
Debugger::Instance()->Show(); // dump collected debug info
```
### Caching an API response
In the controller method:
```php
public function List(): array {
$this->Cache("my_cache_key"); // serve cached if exists
$data = MyControl::GetAll();
$this->Cache("my_cache_key", json_encode($data)); // save
return $data;
}
```
---
## Conventions & Rules
| Rule | Reason |
|------|--------|
| Never instantiate Singletons with `new` | Framework guarantees one instance |
| Always call `Query::Clean()` or use `PrepareAndExecute()` for user input | SQL injection prevention |
| Config secrets use `$=ENV_VAR` in the conf file | Keep secrets out of version control |
| Models return `ToJson()` in API responses (not raw `ToArray()`) | `ToJson()` handles relations recursively |
| Call `MagratheaPHP::LoadVendor()` before anything else | Registers the autoloader |
| Use `->Prod()` in production, `->Dev()` in development | Controls error display and debug verbosity |
| API controllers throw `MagratheaApiException` | Automatically converted to structured JSON error |
---
## Documentation
Full documentation lives in `documentation/`:
| File | Topic |
|------|-------|
| `documentation/index.md` | Master index |
| `documentation/getting-started.md` | Quickstart |
| `documentation/core/` | MagratheaPHP, Config, Singleton, Helper, Functions |
| `documentation/database/` | Database, Query Builder, Model, Control |
| `documentation/api/` | MagratheaApi, ApiControl, Authentication |
| `documentation/admin/` | Admin panel and features |
| `documentation/utilities/` | Cache, Mail, Logger, Debugger, Compressors |
| `documentation/exceptions/` | Exception hierarchy |
| `documentation/advanced/` | Design patterns, Testing |
---
## External Dependencies
| Package | Used for |
|---------|---------|
| `firebase/php-jwt` | JWT token encode/decode |
| `phpmailer/phpmailer` | SMTP email |
| `scssphp/scssphp` | SCSS compilation |
| `tedivm/jshrink` | JS minification |
| `components/jquery` | Admin panel jQuery |
| `twbs/bootstrap` | Admin panel Bootstrap |
---
## Things AI Assistants Should Watch Out For
1. **Don't assume `new Database()` is valid** — it's a singleton.
2. **Query Builder methods return `$this`** — they don't execute SQL.
3. **`MagratheaApiException`** is caught by the router and turned into a JSON error — throw it freely in controllers.
4. **`Config::Get()` reads from the `[default]` section** — use `GetConfig("section/key")` for named sections.
5. **`Save()` on a model auto-detects insert vs. update** — based on whether PK is set.
6. **The admin panel requires `session_start()`** — ensure `StartSession()` is called in bootstrap.
7. **Route parameters use `{param}` syntax** in `MagratheaApi::Add()` — they arrive as array in the controller method.
8. **`MagratheaModelControl` methods are all `static`** — don't call them on instances.
9. **`$dbValues` defines the column schema** — fields not listed here are ignored on insert/update.
10. **`AllowAll()` opens CORS to everyone** — use `Allow([...])` in production for specific origins.
---
## Author & Contact
- **Author:** Paulo Henrique Martins — Platypus Technology
- **Official docs:** https://www.platypusweb.com.br/magratheaphp2
- **License:** MIT
<?php
die; // remove this line once configuration below is correct for your environment
require "../vendor/autoload.php";
Magrathea2\MagratheaPHP::Instance()
->AppPath(realpath(dirname(__FILE__)))
->Dev()
->Load();
Magrathea2\Bootstrap\Start::Instance()->Load();
<?php
require __DIR__ . "/../vendor/autoload.php";
error_reporting(E_ALL);
ini_set("display_errors", "1");
Magrathea2\MagratheaPHP::Instance()
->MinVersion("2.1.19")
->AppPath(realpath(dirname(__FILE__)))
->AddCodeFolder("admin", "api", "api/Authentication")
->AddFeature("Article")
->Load();
// Create and insert$article = new Article();
$article->title = "Hello World";
$article->body = "My first article.";
$article->published = true;
$article->created_at = now();
$id = $article->Save(); // returns new ID// Load by primary key$article = new Article(42);
echo$article->title;
// Update$article->title = "Updated Title";
$article->Save(); // existing PK -> UPDATE// Delete$article->Delete();
// Serialize for an API responsereturn$article->ToArray();
use App\Controls\ArticleControl;
$all = ArticleControl::GetAll();
$published = ArticleControl::GetWhere(["published" => 1]);
$article = ArticleControl::GetRowWhere(["id" => 42]);
$recent = ArticleControl::RunQuery(
"SELECT * FROM articles WHERE created_at > '2024-01-01' ORDER BY created_at DESC LIMIT 5"
);
// Paginated for an API response (no COUNT(*), has_more via limit+1 trick)$query = \Magrathea2\DB\Query::Select()
->Obj(new \App\Models\Article())
->Where(["published" => 1])
->Order("created_at DESC");
$pagination = ArticleControl::GetPagination($query, page: 0, limit: 10);
use Magrathea2\DB\Query;
use Magrathea2\DB\Database;
// SELECT with model + JOIN$sql = Query::Select()
->Obj(\App\Models\Article::class)
->SelectExtra("u.name AS author_name")
->Inner("users u", "u.id = articles.author_id")
->Where(["articles.published" => 1])
->Order("created_at DESC")
->Limit(10)
->SQL();
$rows = Database::Instance()->QueryAll($sql);
// User input must always go through a prepared statement$result = Database::Instance()->PrepareAndExecute(
"SELECT * FROM articles WHERE title LIKE ?",
["s"],
["%{$_GET['search']}%"]
);
namespace App\Api;
use Magrathea2\MagratheaApiControl;
use Magrathea2\Exceptions\MagratheaApiException;
use App\Controls\ArticleControl;
class ArticleApi extends MagratheaApiControl {
publicfunction GetList() {
$page = (int)($this->request["page"] ?? 0);
$query = \Magrathea2\DB\Query::Select()
->Obj(new \App\Models\Article())
->Where(["published" => 1]);
return ArticleControl::GetPagination($query, page: $page, limit: 20);
}
publicfunction Get($id) {
$article = new \App\Models\Article((int)$id);
if (!$article->id) {
thrownew MagratheaApiException("Article not found", 404);
}
return$article->ToArray();
}
}
use Magrathea2\Authentication;
// Issuing a token (login controller)$token = Authentication::Instance()->GenerateToken([
"user_id" => $user->id,
"role" => $user->role,
]);
return ["token" => $token];
// Reading the current user in any other controller$payload = Authentication::Instance()->GetTokenData();
$userId = $payload->user_id ?? null;
use Magrathea2\MagratheaCache;
$key = "articles:published:page:{$page}";
$cached = MagratheaCache::Instance()->Get($key);
if ($cached !== null) {
return$cached;
}
$data = ArticleControl::GetWhere(["published" => 1]);
MagratheaCache::Instance()->Set($key, $data, 300); // secondsreturn$data;
use Magrathea2\Logger;
use Magrathea2\Debugger;
// Production-safe logging
Logger::Instance()->Log("Order #{$order->id} processed", "orders");
// Development-only debug output (silenced unless Dev mode is on)
Debugger::Instance()->Debug($order, "order-debug");
use Magrathea2\MagratheaMailSMTP;
MagratheaMailSMTP::Instance()
->To("someone@example.com", "Someone")
->Subject("Welcome!")
->Body("<p>Thanks for signing up.</p>")
->Send();
<?php
// public/admin.php
require __DIR__ . "/../app/_inc.php";
Magrathea2\Admin\AdminManager::Instance()
->SetAdminClass(\App\Admin\MyAdmin::class)
->Run();
use Magrathea2\Admin\Features\AdminFeatureCrud;
$this->AddFeature(
new AdminFeatureCrud("Articles", \App\Models\Article::class, [
"icon" => "bi-file-text",
"fields" => ["title", "published", "created_at"],
])
);
[general]
use_environment = "default"
time_zone = "America/Sao_Paulo"
[dev]
db_host = "localhost"
db_name = "my_db"
db_user = "root"
db_pass = "secret"
db_port = "3306"
site_path = "/var/www/html"
logs_path = "../logs"
cache_path = "../cache"
timezone = "America/Sao_Paulo"
server_url = "http://localhost:8080"
jwt_key = "a-very-long-random-string-here"
[production]
db_host = "db.prod.server"
db_name = "my_db"
db_user = "app_user"
db_pass = "$=DB_PASSWORD"
db_port = "3306"
site_path = "/var/www/html"
logs_path = "../logs"
cache_path = "../cache"
timezone = "America/Sao_Paulo"
server_url = "https://myapp.example.com"
jwt_key = "$=JWT_SECRET"
use Magrathea2\Config;
// Get() reads a bare key from the ACTIVE environment section// (general/use_environment, or whatever ->SetEnvironment() set)$host = Config::Instance()->Get("db_host");
// GetConfig() reads from the config root, independent of environment -// use "section/key" slash notation to reach into a named section$prodHost = Config::Instance()->GetConfig("production/db_host");
// GetConfigSection() returns any named section as an array// (the section name is required - there is no "active section" default)$prodSection = Config::Instance()->GetConfigSection("production");
echo$prodSection["db_host"];