studiokaizen/framework

ZenPHP — a modern zero-dependency PHP 8.4 micro-framework

Maintainers

Package info

github.com/studiokaizen/framework

pkg:composer/studiokaizen/framework

Statistics

Installs: 7

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.2 2026-05-26 17:41 UTC

This package is auto-updated.

Last update: 2026-05-26 17:42:41 UTC


README

A modern, zero-dependency micro-framework for PHP 8.4.

Requirements

  • PHP 8.4+
  • PDO extension (SQLite or MySQL)
  • OpenSSL extension

Installation

composer require studiokaizen/framework

Getting Started

Copy config.example.php to config.php and fill in your values. Generate an encryption key:

php zen key:generate

Run migrations:

php zen migrate

Routing

Routes are defined in public/index.php. Every handler receives a Request and Response and must return a Response.

use Zen\Http\Request;
use Zen\Http\Response;

$app->get('/', function (Request $request, Response $response) use ($app): Response {
    return $app->view('home');
});

$app->post('/users', function (Request $request, Response $response) use ($app): Response {
    // ...
});

$app->put('/users/:id', function (Request $request, Response $response) use ($app): Response {
    $id = (int) $request->getRouteParam('id');
    // ...
});

$app->patch('/users/:id', function (Request $request, Response $response) use ($app): Response { /* ... */ });
$app->delete('/users/:id', function (Request $request, Response $response) use ($app): Response { /* ... */ });

Route Parameters

$app->get('/posts/:id/comments/:commentId', function (Request $request, Response $response): Response {
    $postId    = (int) $request->getRouteParam('id');
    $commentId = (int) $request->getRouteParam('commentId');
    // ...
});

Route Middleware

$app->get('/dashboard', $handler)->middleware('csrf', 'auth');
$app->post('/api/items', $handler)->middleware('token', 'throttle:60,1');

Route Groups

$app->group('/admin', function () use ($app): void {
    $app->get('/', $handler);
    $app->get('/users', $handler);
}, ['csrf', 'auth']);

Request

$request->all();                          // all input (body + query)
$request->input('name');                  // single input value
$request->input('role', 'user');          // with default
$request->only('name', 'email');          // whitelist
$request->query('page', 1);              // query string value
$request->getRouteParam('id');            // route parameter
$request->getHeader('Authorization');     // request header
$request->isJson();                       // Content-Type: application/json
$request->isAjax();                       // X-Requested-With: XMLHttpRequest
$request->getMethod();                    // GET, POST, etc.
$request->getUri();                       // /path?query
$request->getIp();                        // client IP

Response

return $app->view('template', $data);          // render a view (on $app, not $response)
return $response->json($data);                 // JSON response (200)
return $response->json($data, 201);            // JSON with status
return $response->redirect('/path');           // 302 redirect
return $response->status(404)->body('Not found.');
return $response->header('X-Custom', 'value')->json($data);

Views

Templates live in resources/views/ and use plain PHP.

// resources/views/layout.php
<!DOCTYPE html>
<html>
<head>
    <title><?= $this->e($this->yield('title', 'App')) ?></title>
    <?= $this->stack('css') ?>
</head>
<body>
    <?= $this->yield('content') ?>
    <?= $this->stack('scripts') ?>
</body>
</html>
// resources/views/home.php
<?php $this->extend('layout'); ?>

<?php $this->section('title', 'Home'); ?>

<?php $this->startSection('content'); ?>
    <h1>Hello, <?= $this->e($name) ?></h1>
<?php $this->endSection(); ?>

<?php $this->push('scripts'); ?>
<script src="/js/page.js"></script>
<?php $this->endPush(); ?>

<?php $this->prepend('scripts'); ?>
<script src="/js/vendor.js"></script>
<?php $this->endPrepend(); ?>
// Render from a route handler
return $app->view('home', ['name' => 'World']);

View API reference

Method Description
$this->extend('name') Declare the parent layout template
$this->yield('name', 'default') Output a section in a layout
$this->section('name', 'value') Define a section inline (no buffering)
$this->startSection('name') Begin buffering a section
$this->endSection() End the current section buffer
$this->stack('name') Output a stack in a layout
$this->push('name') Begin buffering content to append to a stack
$this->endPush() End the current push buffer
$this->prepend('name') Begin buffering content to prepend to a stack
$this->endPrepend() End the current prepend buffer
$this->e('string') HTML-escape a value
$this->share('key', $value) Share a variable with all templates

Validation

use Zen\Validation\ValidationException;

try {
    $data = $app->validator($request->all(), [
        'name'     => 'required|min:2|max:100',
        'email'    => 'required|email|max:255',
        'password' => 'required|min:8',
        'age'      => 'integer|min:18',
        'website'  => 'url',
        'role'     => 'in:admin,editor,viewer',
    ])->validate();
} catch (ValidationException $e) {
    $e->errors(); // ['field' => ['message', ...]]
}

Available rules: required, nullable, string, integer, numeric, boolean, array, email, url, min, max, between, size, in, not_in, same, different, confirmed, regex.

Database

Query Builder

// Select
$users = $app['db']->table('users')->get();
$user  = $app['db']->table('users')->find(1);
$user  = $app['db']->table('users')->where('email', 'alice@example.com')->first();

// Chaining
$results = $app['db']->table('posts')
    ->where('user_id', $userId)
    ->where('published', 1)
    ->orderBy('created_at', 'DESC')
    ->limit(10)
    ->get();

// Insert / Update / Delete
$id = $app['db']->table('users')->insert(['name' => 'Alice', 'email' => 'alice@example.com']);
$app['db']->table('users')->where('id', $id)->update(['name' => 'Alice Smith']);
$app['db']->table('users')->where('id', $id)->delete();

// Aggregates
$count = $app['db']->table('users')->count();
$count = $app['db']->table('users')->where('active', 1)->count();

// Raw queries
$rows = $app['db']->select('SELECT * FROM posts WHERE user_id = ?', [$userId]);
$row  = $app['db']->selectOne('SELECT COUNT(*) AS total FROM users');
$app['db']->statement('UPDATE users SET active = 1 WHERE id = ?', [$id]);

Pagination

$total = (int) $app['db']->selectOne('SELECT COUNT(*) AS n FROM posts')->n;
$items = $app['db']->select('SELECT * FROM posts ORDER BY id DESC LIMIT ? OFFSET ?', [$perPage, ($page - 1) * $perPage]);

$paginator = new \Zen\Database\Paginator($items, $total, $perPage, $page);

$paginator->items();
$paginator->total();
$paginator->currentPage();
$paginator->lastPage();
$paginator->hasMorePages();
$paginator->nextPage();
$paginator->previousPage();
$paginator->toArray();

Migrations

php zen make:migration create_posts_table
php zen migrate
php zen migrate:rollback
php zen migrate:fresh --seed
php zen migrate:status

Migration files use -- UP and -- DOWN sections:

-- UP
CREATE TABLE posts (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    title      VARCHAR(255) NOT NULL,
    body       TEXT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- DOWN
DROP TABLE posts;

Authentication

Session Auth

// Login
$app['auth']->attempt($email, $password); // returns bool
$app['auth']->login($userId);
$app['auth']->logout();

// Current user
$app['auth']->check();         // bool
$app['auth']->id();            // int|null
$app['auth']->user();          // array|null

API Token Auth

// Create a token for a user
$token = $app['tokens']->create($userId, 'api-token');

// Revoke
$app['tokens']->revoke($tokenId);

// Current token (inside a 'token' middleware route)
$app['auth']->token();

Use the token middleware to protect API routes. Clients send Authorization: Bearer <token>.

Middleware

Register aliases in bootstrap/app.php:

$app->registerMiddlewareAlias('custom', function ($app) {
    return new \App\Middleware\CustomMiddleware();
});

Built-in aliases: csrf, auth, guest, token, throttle, cors.

Attach to routes:

->middleware('csrf', 'auth')
->middleware('throttle:60,1')   // 60 requests per 1 minute

Custom Middleware

use Zen\Middleware\MiddlewareInterface;
use Zen\Http\Request;
use Zen\Http\Response;

class CustomMiddleware implements MiddlewareInterface
{
    public function handle(Request $request, Response $response, callable $next): Response
    {
        // before
        $response = $next($request, $response);
        // after
        return $response;
    }
}

Cache

$app['cache']->set('key', $value, $ttlSeconds);
$app['cache']->get('key');
$app['cache']->get('key', $default);
$app['cache']->has('key');
$app['cache']->forget('key');

// Compute and cache
$value = $app['cache']->remember('key', 300, function () {
    return expensiveOperation();
});

Events

use Zen\Events\Event;

class UserRegistered extends Event
{
    public function __construct(
        public readonly int    $userId,
        public readonly string $email,
    ) {}
}
// Dispatch
$app['events']->dispatch(new UserRegistered($id, $email));

// Listen
$app['events']->addListener(UserRegistered::class, function (UserRegistered $event) use ($app): void {
    $app['logger']->info("New user #{$event->userId}");
});

Queue

use Zen\Queue\Job;

class SendWelcomeEmail extends Job
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
    ) {}

    public function handle(): void
    {
        // send email
    }
}
// Dispatch
$app['queue']->dispatch(new SendWelcomeEmail($name, $email));

// Dispatch to a named queue
$app['queue']->dispatch(new SendWelcomeEmail($name, $email), queue: 'emails');
php zen queue:work
php zen queue:work --queue=emails
php zen queue:work --max-jobs=10

Scheduler

// In AppServiceProvider::boot()
$app['scheduler']->call(function () use ($app): void {
    // task logic
})->daily();

$app['scheduler']->call(fn () => /* ... */)->hourly();
$app['scheduler']->call(fn () => /* ... */)->everyFifteenMinutes();
$app['scheduler']->call(fn () => /* ... */)->cron('0 9 * * 1'); // every Monday at 9am
# Add to cron — runs every minute
* * * * * php /path/to/project/zen schedule:run

Mail

use Zen\Mail\Message;

$app['mailer']->send(function (Message $message): void {
    $message->to('alice@example.com', 'Alice')
            ->subject('Welcome!')
            ->text('Thanks for signing up.')
            ->html('<p>Thanks for signing up.</p>');
});

// Or pass a Message instance directly
$message = (new Message())
    ->to('alice@example.com', 'Alice')
    ->subject('Welcome!')
    ->text('Thanks for signing up.');

$app['mailer']->send($message);

Configure the driver in config.php (log, smtp, or sendmail).

Session

$app['session']->start();
$app['session']->set('key', $value);
$app['session']->get('key');
$app['session']->get('key', $default);
$app['session']->has('key');
$app['session']->forget('key');
$app['session']->flash('success', 'Saved!');   // one-time message
$app['session']->flash('success');              // read and clear
$app['session']->regenerate();
$app['session']->destroy();

Encryption & Hashing

// Encryption (requires app.key in config.php)
$encrypted = $app['encrypter']->encrypt('secret');
$plain     = $app['encrypter']->decrypt($encrypted);

// Hashing
$hash    = $app['hasher']->make($password);
$isValid = $app['hasher']->verify($password, $hash);

Storage

$app['storage']->disk('local')->put('file.txt', 'contents');
$app['storage']->disk('local')->get('file.txt');
$app['storage']->disk('local')->exists('file.txt');
$app['storage']->disk('local')->delete('file.txt');
$app['storage']->disk('local')->files('/');         // list files

Logging

$app['logger']->info('User logged in', ['id' => $userId]);
$app['logger']->warning('Disk space low');
$app['logger']->error('Payment failed', ['order' => $orderId]);

Logs are written to storage/logs/app.log.

Container

The container uses array-access. Every closure binding is a singleton — resolved once on first access, then frozen.

// Register a service (resolved lazily on first access)
$app['myService'] = fn ($app) => new MyService($app['db']);

// Register a raw value
$app['apiKey'] = 'abc123';

// Protect a closure so it is stored as-is, not invoked as a factory
$app['myCallback'] = $app->protect(fn ($value) => strtolower($value));

// Decorate an existing service
$app->extend('mailer', function ($mailer, $app) {
    $mailer->setLogger($app['logger']);
    return $mailer;
});

// Resolve
$service = $app['myService'];

// Check existence
isset($app['myService']);

// Retrieve the original factory closure (before resolution)
$factory = $app->raw('myService');

Service Providers

use Zen\DependencyInjection\ServiceProviderInterface;
use Zen\DependencyInjection\BootableProviderInterface;

class MyServiceProvider implements ServiceProviderInterface, BootableProviderInterface
{
    public function register(Application $app): void
    {
        $app['myService'] = fn ($app) => new MyService();
    }

    public function boot(Application $app): void
    {
        // runs after all providers are registered
    }
}

Register in bootstrap/app.php:

$app->registerProviders([new MyServiceProvider()]);

Support Utilities

Str

use Zen\Support\Str;

Str::slug('Hello World');           // hello-world
Str::camel('hello_world');          // helloWorld
Str::studly('hello_world');         // HelloWorld
Str::snake('helloWorld');           // hello_world
Str::contains('foobar', 'oba');     // true
Str::startsWith('foobar', 'foo');   // true
Str::endsWith('foobar', 'bar');     // true
Str::limit('Long text...', 10);     // 'Long text…'
Str::random(32);                    // random alphanumeric string

Arr

use Zen\Support\Arr;

Arr::get($array, 'user.address.city', 'Unknown');
Arr::set($array, 'user.name', 'Alice');
Arr::has($array, 'user.email');
Arr::forget($array, 'user.password');
Arr::only($array, ['name', 'email']);
Arr::except($array, ['password']);
Arr::flatten($array);
Arr::pluck($array, 'name');

Collection

use Zen\Support\Collection;

$col = new Collection([1, 2, 3, 4, 5]);

$col->map(fn ($n) => $n * 2);
$col->filter(fn ($n) => $n > 2);
$col->reduce(fn ($carry, $n) => $carry + $n, 0);
$col->first();
$col->last();
$col->count();
$col->toArray();

Console Commands

php zen list                       # list all commands
php zen make:migration <name>      # create a migration
php zen make:seeder <name>         # create a seeder
php zen migrate                    # run pending migrations
php zen migrate:rollback           # roll back last batch
php zen migrate:reset              # roll back all
php zen migrate:fresh              # reset + re-migrate
php zen migrate:fresh --seed       # reset + re-migrate + seed
php zen migrate:status             # show migration status
php zen db:seed                    # run all seeders
php zen queue:work                 # process queued jobs
php zen schedule:run               # run due scheduled tasks
php zen cache:clear                # clear file cache
php zen key:generate               # generate an encryption key

Custom Commands

use Zen\Console\Command;

class GreetCommand extends Command
{
    public function name(): string
    {
        return 'greet';
    }

    public function description(): string
    {
        return 'Greet someone by name.';
    }

    public function handle(array $args, array $options): int
    {
        $name = $args[0] ?? 'World';
        $this->line('Hello, ' . $name . '!');
        return 0;
    }
}

Output helpers available inside handle(): $this->line(), $this->info() (green), $this->warn() (yellow), $this->error() (red).

Register in bootstrap/app.php:

$app['console']->add(new GreetCommand());

License

MIT