Framework
Introduction

Getting Started

Install the framework, configure it, and build a first feature end to end — a table, the typed code generated from it, and a route that serves it.

Requirements

  • PHP 8.1+ with the mysqli, curl, zip, gd and mbstring extensions.
  • Composer for dependency management.
  • A MySQL / MariaDB database for the schema and migration layer.

Two packages are needed by most apps:

  • mustache/mustache — the code generator renders its templates with it, so it is required as soon as you use anything beyond the Utils helpers (routes, schemas, settings, …).
  • firebase/php-jwt — required by the Auth module to issue and validate access & refresh tokens.

The rest are truly optional — pull them in only when you use the feature:

Installation

composer.json
"repositories": [
    { "type": "git", "url": "https://github.com/FrameworkPHPAR/Framework.git" }
],
"require": {
    "frameworkphpar/framework": "dev-main#v0.17.0"
}
./vendor/bin/framework install

Then wire it into a single entry point — your index.php hands each request to Framework::execute(). See Bootstrapping.

Project structure

your-app/
├── src/                 # your application classes (organized however you like)
│   └── Example/
│       ├── ExampleRoutes.php
│       └── ExampleSchema.php
├── config/              # your *.config.php files
│   ├── Access.config.php
│   └── Settings.config.php
├── nls/
│   ├── strings/         # translated strings, one JSON file per language
│   ├── emails/          # email copy per language
│   └── notifications/   # notification copy per language
├── files/               # uploaded / generated files
├── vendor/              # Composer packages (the framework lives here)
├── composer.json        # your project & dependencies
└── .env                 # environment configuration

src/

Your application classes. Discovery scans it recursively, so you can group them into any folders you like — the build finds them wherever they are. Nothing is generated here: the typed System classes (Router, Access, Path…) are written inside the framework package, so there is nothing to edit or commit.

config/

Your *.config.php files. The folder is only a convention — they can live anywhere. See Config Files.

nls/

Translated strings, plus email and notification copy — one JSON file per language.

files/

Uploaded and generated files: sources, thumbnails, avatars and temp files.

.env & composer.json

Environment values (see Configuration) and your project dependencies.

Configuration

There are two layers. Environment values live in a .env file and are read through the Config facade, with keys looked up in constant case (url maps to URL):

.env
URL         = "https://example.com/"
DB_HOST     = "127.0.0.1"
DB_DATABASE = "myapp"

Everything else is wired up in *.config.php files, where you register access roles, settings, file paths and languages:

config/Access.config.php
use Framework\Core\AccessRole;

AccessRole::register("General", "General");
AccessRole::register("Admin", "Admin");

See Configuration and Config Files for the full details.

Build the code

Whenever you add or change a discovered class, regenerate the code:

./framework build

During development, keep it rebuilding on every change:

./framework watch

See the CLI reference for every command.

Your first feature

The pieces above are the setup; this is the loop you repeat. One annotated class describes a table, the build turns it into typed code, and a route exposes it — four files, and nothing registered anywhere central.

1. Describe the table

A model is a plain class of public properties. The attributes say what each one is: which is the id, which the user may send, and which must be filled in.

src/Product/Model/ProductModel.php
namespace App\Product\Model;

use Framework\Database\Model\Model;
use Framework\Database\Model\Field;
use Framework\Database\Model\Requested;
use Framework\Database\Model\Validate;

#[Model(hasTimestamps: true, canCreate: true, canEdit: true, canDelete: true)]
class ProductModel {

    #[Field(isID: true)]
    #[Requested(isID: true)]
    public int $productID;

    #[Field(isUnique: true, length: 100)]
    #[Validate(isRequired: true)]
    #[Requested]
    public string $name;

    #[Field(decimals: 2)]
    #[Requested]
    public float $price;
}

2. Build

./framework build

That writes the six classes for the model — the schema, a typed query, an entity, a column enum, a request and a status enum — next to it in a Schema/ folder. Then create the table from the same model:

./framework migrate

The migration compares the models against the database and asks before it changes anything, so the first run creates products and later runs only add what is new.

3. Write the class

The generated schema is a base class, not something you edit. Yours extends it, and is the only door to the table — the reads are inherited, and the writes are protected so they can only be called from here.

src/Product/Product.php
namespace App\Product;

use App\Product\Schema\ProductSchema;
use App\Product\Schema\ProductQuery;
use App\Product\Schema\ProductRequest;

class Product extends ProductSchema {

    /** Every product, newest first */
    public static function getAll(): array {
        $query = new ProductQuery();
        $query->createdTime->orderByDesc();

        return self::getEntityList($query);
    }

    public static function create(ProductRequest $request): int {
        return self::createEntity($request);
    }
}

4. Expose it

A route is a static method with an attribute. The second argument is the minimum access level, and type-hinting the generated request is what gives you typed properties instead of raw input.

src/Product/ProductConstructor.php
namespace App\Product;

use App\Product\Schema\ProductRequest;

use Framework\Discovery\Attr\Route;
use Framework\System\Access;
use Framework\IO\Response;

class ProductConstructor {

    #[Route("/products/getAll", Access::General)]
    public static function getAll(): Response {
        return Response::data([
            "list" => Product::getAll(),
        ]);
    }

    #[Route("/products/create", Access::Admin)]
    public static function create(ProductRequest $request): Response {
        $result = Product::validateRequest($request);
        if ($result->hasError()) {
            return Response::error($result->errors);
        }

        Product::create($request);
        return Response::success("PRODUCT_CREATED");
    }
}

5. Build again

Routes are discovered at build time, so the new ones do not exist until you rebuild. This is the step that is easy to forget — nothing warns you, the route simply is not there:

./framework build

Now they are live:

curl "https://example.com/api/products/getAll" \
     -H "Authorization: Bearer $TOKEN"

Running ./framework watch instead rebuilds on every save, which is how you want to work while developing — then this step takes care of itself.

What just happened

Nothing was registered in a central file. The build found the model by its attributes and the routes by theirs, and generated the code that connects them — which is why renaming price breaks the build instead of quietly returning null at runtime.

From here: relations pull columns from other tables into the same entity, queries cover the filtering a listing needs, and signals let other code react without this class knowing about it.