MagratheaPHP2

MD Files

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.

skill.md instructions.md
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.

Reference files:


Table of Contents

  1. Project Bootstrap
  2. Configuration
  3. Creating a Model
  4. Creating a Control
  5. Building Queries
  6. Creating an API Class
  7. Creating an API Controller
  8. JWT Authentication
  9. Caching
  10. Logging & Debugging
  11. Sending Email
  12. Admin Panel — Entry Point
  13. Admin Panel — Admin Class
  14. Admin Panel — CRUD Features
  15. Error Handling
  16. Testing
  17. Complete Application Skeleton
  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
// 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:


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

[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

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:

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

TypePHP equivalent
intinteger
booleanbool (stored as TINYINT 0/1)
stringstring (VARCHAR etc.)
textstring (TEXT column)
floatfloat (DECIMAL/FLOAT)
datetimestring in Y-m-d H:i:s format
uuidstring (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 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
// 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
// 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

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.

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:

$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
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
// 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
require_once __DIR__ . '/../vendor/autoload.php';
// ... MagratheaPHP bootstrap ...
return new \App\MyAppApi();

Key rules:


7. Creating an API Controller

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


8. JWT Authentication

Step 1: Auth controller

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

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:


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.

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();
}
$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:

$this->Cache("articles_cat_{$categoryId}", $page);
$this->Cache("user_profile", $userId);
"products_active_sort_price"

10. Logging & Debugging

Logging (production)

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.

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


13. Admin Panel — Admin Class

Extend \Magrathea2\Admin\Admin and implement \Magrathea2\Admin\iAdmin.

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


14. Admin Panel — CRUD Features

Each model gets a companion CRUD admin class extending AdminCrudObject.

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

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

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)

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)

<phpunit bootstrap="vendor/autoload.php">
    <testsuites>
        <testsuite name="App">
            <directory>tests/</directory>
        </testsuite>
    </testsuites>
</phpunit>

Mocking for unit tests

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
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
require_once __DIR__ . "/bootstrap.php";
return new \App\MyAppApi();

public/index.php — HTTP entry point:

<?php
$api = require __DIR__ . "/api.php";
$api->Run();

public/admin.php — admin entry point:

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

Models

Controls

API Class

API Controllers

Admin Class

Admin CRUD Features

Config

General