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.
Enterprise essentials, built in
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 routerclass 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,
]);
}
}
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 docspublic 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// 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");
}
@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.
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();
}
}
$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.
-- 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'
);
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.
{{-- app/Views/components/alert.blade.php --}}
@props(["type" => "info"])
<div class="alert alert-{{ $type }}">
{{ $slot }}
</div>
@extends($layout)
@section("content")
<x-alert type="warning">
Your session is about to expire.
</x-alert>
@auth("employee.edit")
<button>Edit</button>
@endauth
@endsection
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/.
Built for the long term
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.
Pick your channel
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 suitein a real browser
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