Start simple. Grow to enterprise.

ZubZet is an opinionated PHP framework that stays simple until you need more. In production since 2019, actively maintained, and built on open-source components the PHP ecosystem already trusts.

$ composer create-project zubzet/zubzet
Stable v1.2.0 PHP 8.0 – 8.5 Apache-2.0 Since 2019
Show, don't tell

What ZubZet code looks like

ZubZet has one principle: simple by default, more when you need it. Every lightweight tool has a heavier sibling for the day you outgrow it, and the two work together without breaking your project.

Zero config, full control when you need it

Drop a controller into app/Controllers/ and the URL exists. /Employee/view/2 resolves straight to action_view with 2 as a parameter. No route table, no annotations, no cache to rebuild.

The day an API needs HTTP-method compliance, versioned endpoints or middleware, you add the explicit router. It runs alongside your convention routes without breaking a single one of them.

Convention routing The router
The default: controllers become URLs
class EmployeeController extends z_controller {

    // GET {root}/Employee/view/2
    public function action_view(Request $req, Response $res) {
        $req->checkPermission("employee.view");

        $employeeId = $req->getParameters(0, 1);
        $employee = $req->getModel("Employee")->getById($employeeId);

        return $res->render("employee/employee_view", [
            "employee" => $employee,
        ]);
    }
}
When you need more: the explicit router
use ZubZet\Framework\Routing\Route;

Route::group('/api/{apiVersion}', function () {
    Route::get('/users', [UserController::class, 'index']);
    Route::get('/users/{id}', [UserController::class, 'show']);
})
->middleware([AuthController::class, 'checkAuth'])
->afterMiddleware([LogController::class, 'logRequest']);

Forms that validate themselves

Declare your fields once. ZubZet validates the input, ships error messages back to the exact field that failed, and writes the validated result to the database. You never touch $_POST, never hand-map columns, never write the error rendering twice.

It is the code you have written a hundred times, reduced to the few lines that actually differ per form.

Form validation docs
One declaration, the whole round trip
public function action_add(Request $req, Response $res) {
    $req->checkPermission("todo.add");

    if($req->hasFormData()) {
        $formResult = $req->validateForm([
            (new FormField("description"))
                ->required()->length(5, 15),
        ]);

        if($formResult->hasErrors) {
            return $res->formErrors($formResult->errors);
        }

        $res->insertDatabase("todo", $formResult);
        return $res->success();
    }

    return $res->render("todo/add");
}

One permission model, everywhere

Permissions are dotted strings with wildcard support: employee.view is granted by employee.* or *.*. One line guards an action with a 403, and the same permission string shows or hides a button in your view.

Access is decided in exactly one place, and reads the same everywhere it is enforced.

Permission system docs
Guard an action
// Kicks the request out with a 403 when the current user
// is missing "employee.view".
// Also granted by: employee.*  and  *.*
public function action_view(Request $req, Response $res) {
    $req->checkPermission("employee.view");

    return $res->render("employee/employee_view");
}
The same permission in a view
@auth("employee.edit")
    <button>Edit employee</button>
@endauth

Your SQL first, a query builder when it grows

Write the SQL you already know. exec() collapses prepare, bind and execute into one call with real prepared parameters, so untrusted input is never concatenated into the SQL string. Results come back as plain PHP arrays.

When a query outgrows raw SQL, a report, a filter, a dashboard, switch that one method to the query builder. Same model, same exec() call, and the rest of your SQL stays untouched.

Models docs Query builder docs
The default: your SQL, safely
class EmployeeModel extends z_model {

    public function getAll(): array {
        return $this->exec("SELECT * FROM `employee`")->resultToArray();
    }

    public function getById($employeeId): array {
        $sql = "SELECT * FROM `employee` WHERE `id` = ?";
        return $this->exec($sql, "i", $employeeId)->resultToLine();
    }

}
When you need more: the query builder
$query = $this->dbSelect([
        'u.id',
        'u.name',
        'order_count' => 'COUNT(o.id)',
    ], ['u' => 'users'])
    ->leftJoin(['o' => 'orders'], ['u.id = o.user_id'])
    ->where(['u.status' => 'active'])
    ->group(['u.id', 'u.name'])
    ->orderDesc('order_count')
    ->limit(10);

$result = $this->exec($query);

Plain SQL migrations, PHP when you need logic

A schema change is a file in app/Database/migrations/, applied exactly once and in chronological order, and the importer checks the timeline for gaps before importing.

Plain .sql files cover the everyday case. The day one migration needs conditions or computed data, write that one in PHP with a Doctrine DBAL schema builder. Both formats mix freely in the same timeline.

Migrations docs
The default: plain SQL
-- app/Database/migrations/2026-03-03_1_CreateOrders.sql
CREATE TABLE `orders` (
    `id` INT AUTO_INCREMENT PRIMARY KEY,
    `userId` INT NOT NULL,
    `total` DECIMAL(10, 2),
    `status` VARCHAR(50) DEFAULT 'pending'
);
When you need logic: the same migration in PHP
use ZubZet\Framework\Database\Migration\Migration;

// app/Database/migrations/2026-03-03_1_CreateOrders.php
class Migration_2026_03_03_1_CreateOrders extends Migration {

    public function execute(): void {
        $table = $this->tableCreate("orders");
        $table->addColumn("id", "integer", ["autoincrement" => true]);
        $table->setPrimaryKey(["id"]);
        $table->addColumn("userId", "integer", ["notnull" => true]);
        $table->addColumn("total", "decimal", [
            "precision" => 10, "scale" => 2,
        ]);
        $table->addIndex(["userId"], "idx_orders_user");
    }
}

Blade views and components

Views are Blade templates: template inheritance with @extends, reusable components with @props and slots, and the framework's own page essentials shipped as <x-zubzet::...> components you can override.

Even access control reads naturally in a template: the @auth directive speaks the same permission strings as your controllers.

Views docs
A component, defined once
{{-- app/Views/components/alert.blade.php --}}
@props(["type" => "info"])
<div class="alert alert-{{ $type }}">
    {{ $slot }}
</div>
Used anywhere
@extends($layout)

@section("content")
    <x-alert type="warning">
        Your session is about to expire.
    </x-alert>

    @auth("employee.edit")
        <button>Edit</button>
    @endauth
@endsection
AI Ready

Built for AI coding agents

Predictable conventions, public documentation and a strong test suite are exactly what coding agents need. ZubZet leans into that.

  • Agent guide included An AGENTS.md in the repository and a dedicated agents chapter in the docs orient Claude Code, Cursor, Codex and friends.
  • Predictable by convention Convention over configuration means an agent always knows where controllers, models and views live, and where changes belong.
  • A real feedback loop 760+ end-to-end tests against a real database cluster let agents verify their own changes before a human ever reviews them.
  • Docs where agents look The full documentation is public, versioned per release, and ships as Markdown inside the package, readable straight from vendor/.
agent session
$ agent "Add a CSV export to the report page"
Reading AGENTS.md
Reading vendor/zubzet/framework/docs/…
Writing app/Controllers/ReportController.php
Running the e2e suite
Suite green. /Report/exportCsv is routed by convention.
Longevity

Built for the long term

2019 Development begins
2020 First release, v0.9
2026 v1.0, the first major version
Today v1.2.0, the largest release yet
Next v1.3.0-alpha2

Stable and versioned

ZubZet is stable at 1.x. Every version step ships with a documented upgrade guide, all the way back to 0.9, and the ZubZet Version Migrator automates the mechanical steps for you, so updating is routine, not a rewrite.

Proven foundations

Under the hood, ZubZet builds on software the PHP ecosystem trusts: Doctrine DBAL, Symfony Console, Monolog, FastRoute, PHPMailer and the Blade template syntax. A thin, maintainable layer over well-known components, not a private universe.

Actively maintained

Developed and used in production since 2019, with regular releases across the 1.x line, extensive documentation and support for PHP 8.0 through the current 8.5.

Quality Assurance

Tested like production

Every change runs through an end-to-end suite that drives a real browser against a real database cluster, and the suite contains more code than the framework itself. One failover test terminates the active database node mid-request and requires the request to complete successfully. Stable versions ship only after a green run across every supported PHP version, hardened through release candidates and validated in commercial projects.

Browse the test suite
Test suite runs
in a real browser
PHP 8.0 PHP 8.1 PHP 8.2 PHP 8.3 PHP 8.4 PHP 8.5
DB node 1 DB node 2 DB node 3
9,000+ test assertions across versions
Enterprise Support

Commercially proven. Independently maintained.

ZubZet is an independent, maintainer-led open-source project that powers real commercial software. Zierhut IT GmbH has built on ZubZet in production for years and contributes engineering resources to its development. Stewardship of the framework remains with its maintainers, so its continuity is not tied to any single company.

  • Professional and enterprise support
  • Long-term agreements over multiple years
  • Building on ZubZet in production since 2019