Home GitHub

Installation

Requirements

  • PHP >= 8.2
  • Composer

Install via Composer

bash
composer require pollauf/nyoze

Project Directory Structure

my-project/
├── App/
│   ├── Entities/
│   ├── Actions/
│   ├── Repositories/
│   ├── Resources/
│   ├── Rules/
│   └── Support/
├── vendor/
├── composer.json
├── index.php
└── .env
DirectoryPurpose
App/Entities/One EntityDefinition class per entity.
App/Actions/Action classes with business logic. Group by domain.
App/Repositories/Custom repository classes wrapping Repository.
App/Resources/Resource transformers for output shaping.
App/Rules/Rule classes for entity invariants.
App/Support/Helper classes and utilities.

PSR-4 Autoloading

composer.json
{
    "require": {
        "php": ">=8.2",
        "pollauf/nyoze": "*"
    },
    "autoload": {
        "psr-4": {
            "App\\\\": "App/"
        }
    }
}

Then run: composer dump-autoload


Quick Start

1. Create the Entry Point

index.php
<?php
require __DIR__ . '/vendor/autoload.php';

use App\Context;
use Nyoze\Core\Kernel;
use Nyoze\Data\PdoRepository;
use Nyoze\Support\Config;

$config = Config::fromEnv(__DIR__ . '/.env');

$kernel = Kernel::load(function (\Nyoze\Core\App $app) use ($config) {
    $dsn = $config->get('DB_DSN', 'sqlite:database.sqlite');
    $pdo = new PDO($dsn, $config->get('DB_USER'), $config->get('DB_PASS'));
    $app->useRepository(new PdoRepository($pdo));
    (new Context())->register($app);
});

$kernel->app()->run();
  • Kernel::load() creates the application
  • App::useRepository() sets the data layer
  • Context::register() loads entity definitions
  • $kernel->app()->run() starts the HTTP engine

2. Create a Context Class

App/Context.php
<?php
namespace App;

use App\Entities\TaskEntity;
use Nyoze\Core\App;

class Context
{
    public function register(App $app): void
    {
        $app->load([
            TaskEntity::class,
        ]);
    }
}

3. Define Your First Entity

App/Entities/TaskEntity.php
<?php
namespace App\Entities;

use App\Actions\Tasks\CreateTaskAction;
use App\Actions\Tasks\ListTasksAction;
use Nyoze\Domain\Entity;
use Nyoze\Domain\EntityDefinition;
use Nyoze\Domain\Field;

class TaskEntity extends EntityDefinition
{
    public function name(): string { return 'tasks'; }

    public function define(Entity $entity): void
    {
        $entity
            ->fields(
                Field::string('title')->required(),
                Field::text('description'),
                Field::string('status')->default('pending'),
                Field::datetime('created_at')->defaultNow(),
            )
            ->can('create', CreateTaskAction::class)->post()
            ->can('list', ListTasksAction::class)->get();
    }
}

4. Create an Action

App/Actions/Tasks/CreateTaskAction.php
<?php
namespace App\Actions\Tasks;

use Nyoze\Domain\ActionContext;
use Nyoze\Domain\Result;

class CreateTaskAction
{
    public function __invoke(ActionContext $ctx): Result
    {
        // No need to check if title is empty — Field::string('title')->required()
        // already guarantees presence before the handler runs.

        $task = $ctx->repo()->save('tasks', [
            'title'       => $ctx->input('title'),
            'description' => $ctx->input('description', ''),
            'status'      => $ctx->input('status', 'pending'),
            'created_at'  => $ctx->now(),
        ]);

        return Result::created($task);
    }
}

ListTasksAction:

App/Actions/Tasks/ListTasksAction.php
class ListTasksAction
{
    public function __invoke(ActionContext $ctx): Result
    {
        $tasks = $ctx->repo()->all('tasks');
        return Result::ok($tasks);
    }
}

5. HTTP Endpoints

MethodEndpointAction
POST/tasks/createCreateTaskAction
GET/tasks/listListTasksAction

Entity

Entities are the core building block of a Nyoze application. Every domain concept — users, projects, sessions, orders — is modeled as an entity. An entity declares its fields, actions, invariants, relations, and hooks in a single class.

EntityDefinition

Every entity extends the abstract class Nyoze\Domain\EntityDefinition, which requires two methods:

use Nyoze\Domain\Entity;
use Nyoze\Domain\EntityDefinition;

abstract class EntityDefinition
{
    abstract public function name(): string;
    abstract public function define(Entity $entity): void;
}
  • name(): string — Returns the entity name (typically the database table name).
  • define(Entity $entity): void — Configures the entity using the fluent Entity API.

Basic Example

use Nyoze\Domain\Entity;
use Nyoze\Domain\EntityDefinition;
use Nyoze\Domain\Field;

class UserEntity extends EntityDefinition
{
    public function name(): string { return 'users'; }

    public function define(Entity $entity): void
    {
        $entity
            ->fields(
                Field::string('name')->required(),
                Field::email('email')->required()->unique(),
                Field::password('password')->hidden(),
            );
    }
}

Entity Fluent API

The Entity class provides a chainable API for configuring every aspect of an entity. All methods return $this (the Entity instance) so you can chain them together.

Configuration Methods

auth(): self

Marks the entity as requiring authentication. All actions on this entity will require a valid authenticated user.

$entity->auth();

ownedBy(string $field): self

Declares that records in this entity belong to a user, identified by the given field. Automatically enables auth(). The framework uses this to scope queries and enforce ownership.

$entity
    ->auth()
    ->ownedBy('id_user');

virtual(): self

Marks the entity as virtual — it has no database table. Virtual entities are used for action-only endpoints like authentication.

$entity->virtual();

Defining Fields

fields(FieldBuilder ...$builders): self

Declares the entity's fields. Accepts one or more FieldBuilder instances (created via Field:: static methods). See the Field documentation for all available field types and modifiers.

$entity->fields(
    Field::ref('id_user', 'users')->required(),
    Field::string('title')->required(),
    Field::string('status')->default('draft'),
    Field::text('body'),
    Field::integer('sort_order')->default(0),
);

EntityAction — Built-in CRUD Capabilities

EntityAction declares standard CRUD operations as explicit capabilities with automatic HTTP method inference. No need to chain ->get(), ->post(), etc.

Design Principle: Entities do not "have CRUD". Entities expose capabilities. Everything is an action.

use Nyoze\Domain\EntityAction;

$entity
    ->can(EntityAction::create())   // POST /api/{entity}
    ->can(EntityAction::list())     // GET  /api/{entity}
    ->can(EntityAction::get())      // GET  /api/{entity}/:id
    ->can(EntityAction::update())   // PUT  /api/{entity}/:id
    ->can(EntityAction::delete());  // DELETE /api/{entity}/:id

Or declare all five at once:

use Nyoze\Domain\EntityAction;

$entity->can(EntityAction::all());

Static Methods

MethodHTTP MethodRoute Pattern
EntityAction::create()POST/api/{entity}
EntityAction::list()GET/api/{entity}
EntityAction::get()GET/api/{entity}/:id
EntityAction::update()PUT/api/{entity}/:id
EntityAction::delete()DELETE/api/{entity}/:id
EntityAction::all()All aboveAll above

Validation Rules

  • Duplicate action names throw InvalidArgumentException
  • Reserved names (create, list, get, update, delete) cannot be used as custom actions with a handler
  • EntityAction does not accept HTTP method override via chaining

Declaring Actions

can(string|EntityAction|array $nameOrAction, Closure|string|null $handler = null, string $method = 'POST'): self

Registers a capability on the entity. Accepts an EntityAction for built-in CRUD (with automatic HTTP method inference), an array from EntityAction::all(), or a string name with an explicit handler for custom actions.

$entity
    ->can('profile', UserProfileAction::class)->get()
    ->can('updateProfile', UpdateUserProfileAction::class)->put()
    ->can('changePassword', ChangePasswordAction::class)->post();

HTTP Method Chaining

After ->can(), chain one of these to set the HTTP method:

MethodHTTP VerbTypical Use
get(): selfGETRead operations
post(): selfPOSTCreate or execute operations
put(): selfPUTUpdate operations
delete(): selfDELETEDelete operations

These methods set the HTTP verb on the most recently declared action.

Note: HTTP method chaining is only for custom actions. EntityAction methods are inferred automatically. Chaining ->post() after EntityAction::create() will throw an InvalidArgumentException.

Invariants

invariant(Closure|string $checker, string $message = 'Invariant violated'): self

Registers a domain consistency rule that must hold true for the entity state after an action. The checker can be a Rule class name, a callable class, or a Closure. If the checker is a Rule class, the message is automatically extracted from the rule's message() method. Invariants should not duplicate simple field validations like required() — they protect domain coherence, not data presence.

use App\Rules\PublishedProjectHasSections;

// Using a Rule class
$entity->invariant(PublishedProjectHasSections::class);

// Using an inline closure
$entity->invariant(
    fn(array $data) => $data['status'] !== 'completed' || ($data['deliverable_count'] ?? 0) > 0,
    'Completed projects must have at least one deliverable'
);

See the Rules documentation for details on creating Rule classes.

Relations

hasMany(string $entity, string $foreignKey, ?string $localKey = 'id'): self

Declares a one-to-many relationship.

$entity->hasMany('sections', 'id_project');

hasOne(string $entity, string $foreignKey, ?string $localKey = 'id'): self

Declares a one-to-one relationship.

$entity->hasOne('profile', 'id_user');

belongsTo(string $entity, string $foreignKey, ?string $localKey = 'id'): self

Declares an inverse relationship (this entity references another).

$entity->belongsTo('users', 'id_user');

Hooks

before(string $event, Closure|string $handler): self

Registers a hook that runs before a named action or event. The handler receives (mixed $data, ActionContext $ctx) and can return modified data or a Result to short-circuit execution.

$entity->before('create', function(mixed $data, ActionContext $ctx) {
    $data['created_at'] = $ctx->now();
    return $data;
});

after(string $event, Closure|string $handler): self

Registers a hook that runs after a named action or event completes. The handler receives (mixed $data, ActionContext $ctx).

$entity->after('register', function(mixed $data, ActionContext $ctx) {
    // Send welcome email, log event, etc.
});

when(string $field, mixed $value, Closure|string $handler): self

Registers a hook that fires when a specific field has a specific value in the action result. The handler receives (array $data, ActionContext $ctx).

$entity->when('status', 'published', function(array $data, ActionContext $ctx) {
    // Notify subscribers when status becomes "published"
});

See the Hooks documentation for details on the pipeline execution order.

Complete Examples

Here's a full entity definition combining fields, actions, invariants, and ownership:

App/Entities/ProjectEntity.php
use App\Actions\Projects\DuplicateProjectAction;
use App\Rules\PublishedProjectHasSections;
use Nyoze\Domain\Entity;
use Nyoze\Domain\EntityAction;
use Nyoze\Domain\EntityDefinition;
use Nyoze\Domain\Field;

class ProjectEntity extends EntityDefinition
{
    public function name(): string { return 'projects'; }

    public function define(Entity $entity): void
    {
        $entity
            ->auth()
            ->ownedBy('id_user')
            ->fields(
                Field::ref('id_user', 'users')->required(),
                Field::string('title')->required(),
                Field::string('status')->default('draft'),
                Field::text('body'),
            )
            ->can(EntityAction::all())
            ->can('duplicate', DuplicateProjectAction::class)->post()
            ->invariant(PublishedProjectHasSections::class);
    }
}

And a virtual entity with no database table:

App/Entities/SessionEntity.php
use App\Actions\Auth\LoginAction;
use App\Actions\Auth\RegisterAction;
use App\Actions\Auth\LogoutAction;
use Nyoze\Domain\Entity;
use Nyoze\Domain\EntityDefinition;

class SessionEntity extends EntityDefinition
{
    public function name(): string { return 'sessions'; }

    public function define(Entity $entity): void
    {
        $entity
            ->virtual()
            ->can('login', LoginAction::class)->post()
            ->can('register', RegisterAction::class)->post()
            ->can('logout', LogoutAction::class)->post();
    }
}

Field

Fields define the structure of an entity. Nyoze provides a declarative Field factory with static methods for each data type, returning a FieldBuilder that supports chainable modifiers.

Field Factory

The Nyoze\Domain\Field class provides static methods that create a FieldBuilder for each supported type:

use Nyoze\Domain\Field;

$entity->fields(
    Field::string('name')->required(),
    Field::email('email')->required()->unique(),
    Field::password('password')->hidden(),
    Field::boolean('active')->default(true),
);

Available Field Types

MethodSignatureFieldTypeDescription
idid(string $name = 'id'): FieldBuilderIdPrimary key identifier
stringstring(string $name): FieldBuilderStringShort text (varchar)
texttext(string $name): FieldBuilderTextLong text content
integerinteger(string $name): FieldBuilderIntegerStandard integer
bigintbigint(string $name): FieldBuilderBigIntLarge integer
decimaldecimal(string $name): FieldBuilderDecimalDecimal number
booleanboolean(string $name): FieldBuilderBooleanTrue/false value
datetimedatetime(string $name): FieldBuilderDateTimeDate and time
datedate(string $name): FieldBuilderDateDate only
emailemail(string $name): FieldBuilderEmailEmail address
passwordpassword(string $name): FieldBuilderPasswordPassword (hashed)
moneymoney(string $name): FieldBuilderMoneyMonetary value
jsonjson(string $name): FieldBuilderJsonJSON data
enumenum(string $name, string $enumClass): FieldBuilderEnumPHP enum value
refref(string $name, string $entity): FieldBuilderRefForeign key reference

Special Field Types

enum

The enum method takes a second argument — the fully qualified class name of a PHP enum:

Field::enum('status', OrderStatus::class)

This automatically calls enumClass() on the builder.

ref

The ref method takes a second argument — the name of the referenced entity:

Field::ref('id_user', 'users')->required()
Field::ref('id_project', 'projects')->required()

This automatically calls references() on the builder.

FieldBuilder Modifiers

Every Field:: method returns a FieldBuilder instance. Chain modifiers to configure the field's behavior:

required(): self

Marks the field as required. Required fields must have a value when creating or updating records.

Field::string('name')->required()

unique(): self

Marks the field as unique. The framework enforces that no two records share the same value for this field.

Field::email('email')->required()->unique()

hidden(): self

Marks the field as hidden. Hidden fields are excluded from default API responses (useful for passwords and internal data).

Field::password('password')->hidden()

nullable(): self

Marks the field as nullable. The field can explicitly hold a null value.

Field::string('avatar_url')->nullable()

default(mixed $value): self

Sets a default value for the field. When a record is created without this field, the default is used.

Field::string('status')->default('draft')
Field::boolean('active')->default(true)
Field::integer('sort_order')->default(0)

defaultNow(): self

Sets the default value to the current datetime (Y-m-d H:i:s format). Useful for timestamp fields.

Field::datetime('created_at')->defaultNow()

label(string $label): self

Sets a human-readable label for the field. Used in form generation and error messages.

Field::string('name')->required()->label('Full Name')

max(int $length): self

Sets the maximum length for the field value.

Field::string('title')->required()->max(255)

min(int $length): self

Sets the minimum length for the field value.

Field::string('password')->required()->min(8)

references(string $entity): self

Sets the referenced entity for a foreign key field. This is called automatically by Field::ref(), but can be used directly on any FieldBuilder.

Field::string('id_user')->references('users')

enumClass(string $class): self

Sets the PHP enum class for an enum field. This is called automatically by Field::enum(), but can be used directly on any FieldBuilder.

Field::string('status')->enumClass(OrderStatus::class)

FieldType Enum

The Nyoze\Domain\FieldType enum defines all supported field types:

enum FieldType: string
{
    case Id       = 'id';
    case String   = 'string';
    case Text     = 'text';
    case Integer  = 'integer';
    case BigInt   = 'bigint';
    case Decimal  = 'decimal';
    case Boolean  = 'boolean';
    case DateTime = 'datetime';
    case Date     = 'date';
    case Email    = 'email';
    case Password = 'password';
    case Money    = 'money';
    case Json     = 'json';
    case Enum     = 'enum';
    case Ref      = 'ref';
}

FieldDefinition

When the entity is built, each FieldBuilder produces an immutable FieldDefinition object via its build() method. You don't interact with FieldDefinition directly — it's used internally by the framework. Its properties are:

PropertyTypeDescription
namestringField name
typeFieldTypeField type enum
requiredboolWhether the field is required
uniqueboolWhether the field must be unique
hiddenboolWhether the field is hidden from output
nullableboolWhether the field accepts null
defaultmixedDefault value (if set)
hasDefaultboolWhether a default was explicitly set
label?stringHuman-readable label
refEntity?stringReferenced entity name (for ref fields)
enumClass?stringPHP enum class (for enum fields)
maxLength?intMaximum length constraint
minLength?intMinimum length constraint

Complete Example

App/Entities/UserEntity.php
use Nyoze\Domain\Entity;
use Nyoze\Domain\EntityDefinition;
use Nyoze\Domain\Field;

class UserEntity extends EntityDefinition
{
    public function name(): string { return 'users'; }

    public function define(Entity $entity): void
    {
        $entity
            ->auth()
            ->fields(
                Field::string('name')->required(),
                Field::email('email')->required()->unique(),
                Field::password('password')->hidden(),
                Field::string('language')->default('pt-BR'),
                Field::string('avatar_url'),
                Field::boolean('onboarding_done')->default(false),
                Field::boolean('active')->default(true),
            );
    }
}

Action

Actions contain the business logic of your application. Each action is a PHP class with a single __invoke method that receives an ActionContext and returns a Result.

The Action Pattern

An action is any class that implements:

public function __invoke(ActionContext $ctx): Result

There is no interface to implement or base class to extend. The framework identifies actions by their __invoke signature.

Basic Example

use Nyoze\Domain\ActionContext;
use Nyoze\Domain\Result;

class UserProfileAction
{
    public function __invoke(ActionContext $ctx): Result
    {
        $user = $ctx->repo()->find('users', $ctx->userId());

        if (!$user) {
            return Result::notFound('User not found');
        }

        return Result::ok($user);
    }
}

Attaching Actions to Entities

Actions are attached to entities using the ->can() method. For built-in CRUD operations, use EntityAction. For custom actions, pass a string name with a handler and chain an HTTP method:

use Nyoze\Domain\EntityAction;

$entity
    ->can(EntityAction::all())
    ->can('profile', UserProfileAction::class)->get()
    ->can('changePassword', ChangePasswordAction::class)->post();

The first argument is the action name (used in the URL route), and the second is the handler class. The chained HTTP method (->get(), ->post(), ->put(), ->delete()) determines how the action is exposed via HTTP.

You can also use a Closure as the handler:

$entity->can('ping', fn(ActionContext $ctx) => Result::ok(['pong' => true]))->get();

Generated Routes

Each route depends on the corresponding capability declaration:

DeclarationMethodRouteHandler
EntityAction::list()GET/api/usersBuilt-in list
EntityAction::create()POST/api/usersBuilt-in create
EntityAction::get()GET/api/users/:idBuilt-in get
EntityAction::update()PUT/api/users/:idBuilt-in update
EntityAction::delete()DELETE/api/users/:idBuilt-in delete
->can('profile', ...)->get()GET/api/users/:id/profileUserProfileAction

ActionContext API

Every action receives an ActionContext instance. This is the single entry point for accessing input data, the current user, the repository, and utility methods.

entity(): ?array

Returns the current entity record as an associative array. Returns null for virtual entities or when creating new records.

$project = $ctx->entity();
// ['id' => 1, 'title' => 'My Project', 'status' => 'draft', ...]

data(): array

Returns all input data as an associative array. This is the full request body.

$allInput = $ctx->data();
// ['name' => 'John', 'email' => 'john@example.com', ...]

input(string $key, mixed $default = null): mixed

Returns a single input value by key, with an optional default.

$email = $ctx->input('email');
$language = $ctx->input('language', 'en');

param(string $key, mixed $default = null): mixed

Returns a route parameter by key. Route parameters come from the URL path (e.g., the id in /users/42/profile).

$id = $ctx->param('id');

userId(): int

Returns the authenticated user's ID. Returns 0 if no user is authenticated.

$userId = $ctx->userId();

repo(): Repository

Returns the repository instance. Shorthand for repository(). The repository provides transaction methods (beginTransaction(), commit(), rollBack(), transaction()) for wrapping multi-write operations. Use $ctx->repo()->transaction(fn($repo) => ...) for automatic commit/rollback handling.

$user = $ctx->repo()->find('users', $ctx->userId());
$allProjects = $ctx->repo()->all('projects', ['id_user' => $ctx->userId()]);

repository(): ?Repository

Returns the repository instance (alias for repo()). Returns null if no repository is configured.

$repo = $ctx->repository();

hash(string $value): string

Hashes a password using PHP's password_hash() with PASSWORD_DEFAULT.

$hashedPassword = $ctx->hash($ctx->input('password'));

verifyHash(string $value, string $hash): bool

Verifies a plain-text value against a hash using password_verify().

if (!$ctx->verifyHash($password, $user['password'])) {
    return Result::fail('Invalid credentials');
}

now(): string

Returns the current datetime as a Y-m-d H:i:s string.

$timestamp = $ctx->now();
// "2024-01-15 14:30:00"

dispatch(string $entity, string $action, array $payload = [], ?string $id = null): Result

Dispatches an action on another entity through the full pipeline. This is the only correct way for an action to invoke logic on another entity. The dispatched action runs through the complete lifecycle: before hooks → handler → invariants → when hooks → after hooks. The transaction is shared — all writes are atomic.

// Dispatch to another entity
$result = $ctx->dispatch('credits', 'initialize', [
    'id_user' => $user['id'],
    'plan_credits' => 30,
]);

if (!$result->success) {
    return $result; // Propagate failure — triggers rollback
}

Parameters:

  • $entity — Target entity name (e.g. 'credits', 'subscriptions')
  • $action — Action name on that entity
  • $payload — Input data for the action
  • $id — Optional record ID (for non-virtual entities)

Returns a Result — check $result->success before continuing.

execution(): ?ExecutionContext

Accesses the execution context for the current dispatch chain. Returns null if the action was not invoked through the pipeline.

$exec = $ctx->execution();
$exec->correlationId();  // Unique ID for this request
$exec->depth();          // Current nesting depth (0 = root)
$exec->callStack();      // Array of entity/action pairs
$exec->rootEntity();     // Root entity name
$exec->rootAction();     // Root action name
$exec->parentEntity();   // Parent entity (null if root)
$exec->parentAction();   // Parent action (null if root)

Result

Actions return a Result object. The Result class provides static constructors for common response types:

MethodHTTP StatusUse Case
Result::ok($data)200Successful operation
Result::created($data)201Resource created
Result::noContent()204Success with no body
Result::fail($message)400Client error
Result::unauthorized($message)401Not authenticated
Result::forbidden($message)403Not authorized
Result::notFound($message)404Resource not found
Result::invalid($message)422Validation failure
Result::redirect($url)302Redirect
Result::json($data)200JSON response

Complete Examples

Here's a login action that validates input, looks up a user, verifies the password, and returns a token:

LoginAction.php
use App\Repositories\UserRepository;
use App\Resources\UserResource;
use App\Support\TokenHelper;
use Nyoze\Domain\ActionContext;
use Nyoze\Domain\Result;

class LoginAction
{
    public function __invoke(ActionContext $ctx): Result
    {
        $email    = $ctx->input('email');
        $password = $ctx->input('password');

        if (!$email || !$password) {
            return Result::fail('Email and password are required');
        }

        $users = new UserRepository($ctx->repo());
        $user  = $users->findByEmail($email);

        if (!$user || !$ctx->verifyHash($password, $user['password'])) {
            return Result::fail('Invalid credentials');
        }

        return Result::ok([
            'token' => TokenHelper::generate($user['id']),
            'user'  => UserResource::make($user),
        ]);
    }
}

And a registration action that checks for duplicates, hashes the password, creates the user, and initializes credits through dispatch:

RegisterAction.php
use App\Repositories\UserRepository;
use App\Resources\UserResource;
use App\Support\TokenHelper;
use Nyoze\Domain\ActionContext;
use Nyoze\Domain\Result;

class RegisterAction
{
    public function __invoke(ActionContext $ctx): Result
    {
        $name     = $ctx->input('name');
        $email    = $ctx->input('email');
        $password = $ctx->input('password');

        if (!$name || !$email || !$password) {
            return Result::fail('Name, email and password are required');
        }

        $users = new UserRepository($ctx->repo());

        if ($users->emailExists($email)) {
            return Result::fail('Email already registered');
        }

        $user = $users->save([
            'name'     => $name,
            'email'    => $email,
            'password' => $ctx->hash($password),
        ]);

        // Initialize credits through the pipeline
        $creditResult = $ctx->dispatch('credits', 'initialize', [
            'id_user' => $user['id'],
        ]);

        if (!$creditResult->success) {
            return $creditResult;
        }

        return Result::created([
            'token' => TokenHelper::generate($user['id']),
            'user'  => UserResource::make($user),
        ]);
    }
}

Repository

Repositories handle data persistence in Nyoze. The framework defines a Repository interface and ships with a PdoRepository implementation for SQL databases. A fluent Query builder is available for complex queries.

Repository Interface

The Nyoze\Data\Repository interface defines twelve methods — seven for data access and five for transaction control:

namespace Nyoze\Data;

interface Repository
{
    // Data access
    public function find(string $table, int|string $id): ?array;
    public function findBy(string $table, array $conditions): ?array;
    public function all(string $table, array $conditions = [], ?string $orderBy = null,
        ?string $direction = 'ASC', ?int $limit = null, ?int $offset = null): array;
    public function save(string $table, array $data): array;
    public function update(string $table, array $data, array $conditions): int;
    public function delete(string $table, int|string $id): bool;
    public function query(): Query;

    // Transaction control
    public function beginTransaction(): void;
    public function commit(): void;
    public function rollBack(): void;
    public function inTransaction(): bool;
    public function transaction(callable $callback): mixed;
}

Method Reference

find(string $table, int|string $id): ?array

Finds a single record by its primary key. Returns null if not found.

$user = $repo->find('users', 42);
// ['id' => 42, 'name' => 'John', 'email' => 'john@example.com', ...]

findBy(string $table, array $conditions): ?array

Finds a single record matching the given conditions. Returns the first match or null.

$user = $repo->findBy('users', ['email' => 'john@example.com']);

all(string $table, array $conditions = [], ...): array

Returns all records matching the conditions, with optional ordering and pagination.

// All active users
$users = $repo->all('users', ['active' => true]);

// Ordered by name, limited to 10
$users = $repo->all('users', [], 'name', 'ASC', 10);

// With offset for pagination
$users = $repo->all('users', [], 'created_at', 'DESC', 20, 40);

save(string $table, array $data): array

Inserts a new record or updates an existing one (if id is present in the data). Returns the saved record with its ID.

// Insert (no id)
$user = $repo->save('users', [
    'name'  => 'John',
    'email' => 'john@example.com',
]);
// ['id' => 1, 'name' => 'John', 'email' => 'john@example.com']

// Update (id present)
$user = $repo->save('users', [
    'id'   => 1,
    'name' => 'John Doe',
]);

update(string $table, array $data, array $conditions): int

Updates records matching the conditions. Returns the number of affected rows.

$affected = $repo->update('users', ['active' => false], ['id' => 42]);

delete(string $table, int|string $id): bool

Deletes a record by its primary key. Returns true if a row was deleted.

$deleted = $repo->delete('users', 42);

query(): Query

Returns a new Query builder instance for complex queries.

$results = $repo->query()
    ->select('id', 'name', 'email')
    ->from('users')
    ->where('active', true)
    ->orderBy('name')
    ->limit(10)
    ->get();

Transaction Methods

beginTransaction(): void

Starts a database transaction. Nested calls are safe — only the outermost transaction controls the actual commit/rollback.

$repo->beginTransaction();

commit(): void

Commits the current transaction. In nested transactions, only the outermost commit applies the changes.

$repo->commit();

rollBack(): void

Rolls back the current transaction. Always rolls back to the outermost transaction, regardless of nesting depth.

$repo->rollBack();

inTransaction(): bool

Returns true if a transaction is currently active.

if ($repo->inTransaction()) {
    // inside a transaction
}

transaction(callable $callback): mixed

Executes the callback inside a transaction. Commits automatically on success and rolls back on any exception. Returns the value returned by the callback. This is the preferred method for transactional operations.

// Preferred: automatic commit/rollback
$result = $ctx->repo()->transaction(function($repo) {
    $repo->save('orders', $orderData);
    $repo->save('order_items', $itemData);
    return Result::created($order);
});

// Manual control (when needed)
$repo->beginTransaction();
try {
    $repo->save('users', $userData);
    $repo->save('profiles', $profileData);
    $repo->commit();
} catch (\Throwable $e) {
    $repo->rollBack();
    throw $e;
}

PdoRepository

Nyoze\Data\PdoRepository is the built-in SQL implementation of the Repository interface. It wraps a PDO connection.

PdoRepository implements transactions with nesting support via a depth counter (transactionDepth). Only the outermost transaction interacts with the real PDO connection — inner beginTransaction()/commit() calls simply increment and decrement the counter. A rollBack() at any depth always rolls back the entire outermost transaction.

use Nyoze\Data\PdoRepository;

$pdo  = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'pass');
$repo = new PdoRepository($pdo);

The constructor configures PDO to throw exceptions on errors and return associative arrays by default. PdoRepository also exposes the underlying PDO instance:

$pdo = $repo->pdo();

Query Builder

The Nyoze\Data\Query interface provides a fluent API for building complex queries. You get a Query instance from $repo->query().

Methods

select(string ...$columns): self

Specifies which columns to select. If not called, all columns (*) are selected.

$repo->query()->select('id', 'name', 'email')

from(string $table): self

Sets the table to query from.

$repo->query()->from('users')

where(string $column, mixed $value, string $operator = '='): self

Adds a WHERE condition. The default operator is =.

$repo->query()->from('users')->where('active', true)
$repo->query()->from('users')->where('age', 18, '>=')

whereIn(string $column, array $values): self

Adds a WHERE IN condition.

$repo->query()->from('users')->whereIn('id', [1, 2, 3])

orderBy(string $column, string $direction = 'ASC'): self

Adds an ORDER BY clause.

$repo->query()->from('users')->orderBy('created_at', 'DESC')

limit(int $limit): self

Limits the number of results.

$repo->query()->from('users')->limit(10)

offset(int $offset): self

Skips a number of results (for pagination).

$repo->query()->from('users')->limit(10)->offset(20)

join(string $table, string $on, string $type = 'INNER'): self

Adds a JOIN clause. The $type parameter supports INNER, LEFT, RIGHT, etc.

$repo->query()
    ->select('projects.title', 'users.name')
    ->from('projects')
    ->join('users', 'users.id = projects.id_user', 'LEFT')

get(): array

Executes the query and returns all matching rows as an array of associative arrays.

$users = $repo->query()
    ->from('users')
    ->where('active', true)
    ->orderBy('name')
    ->get();

first(): ?array

Executes the query and returns the first matching row, or null if none found.

$user = $repo->query()
    ->from('users')
    ->where('email', 'john@example.com')
    ->first();

count(): int

Returns the count of matching rows.

$total = $repo->query()
    ->from('users')
    ->where('active', true)
    ->count();

exists(): bool

Returns true if at least one matching row exists.

$hasAdmin = $repo->query()
    ->from('users')
    ->where('role', 'admin')
    ->exists();

Custom Repositories

For domain-specific queries, create custom repository classes that wrap the base Repository:

App/Repositories/UserRepository.php
use Nyoze\Data\Repository;

class UserRepository
{
    public function __construct(
        private readonly Repository $repo,
    ) {}

    public function findById(int $id): ?array
    {
        return $this->repo->find('users', $id);
    }

    public function findByEmail(string $email): ?array
    {
        return $this->repo->findBy('users', ['email' => $email]);
    }

    public function emailExists(string $email): bool
    {
        return $this->repo->query()
            ->from('users')
            ->where('email', $email)
            ->exists();
    }

    public function save(array $data): array
    {
        return $this->repo->save('users', $data);
    }
}

Use custom repositories in actions via $ctx->repo():

class UserProfileAction
{
    public function __invoke(ActionContext $ctx): Result
    {
        $users = new UserRepository($ctx->repo());
        $user  = $users->findById($ctx->userId());

        if (!$user) {
            return Result::notFound('User not found');
        }

        return Result::ok(UserResource::make($user));
    }
}

Resource

Resources transform raw data into clean output shapes. They hide internal fields, rename keys, and format values before data is sent to the client.

Resource Abstract Class

The Nyoze\Domain\Resource class is an abstract class with one method to implement:

namespace Nyoze\Domain;

abstract class Resource
{
    abstract protected function transform(array $data): array;

    public static function make(array $data): array;
    public static function collection(array $items): array;
}

Creating a Resource

Extend Resource and implement the transform() method. This method receives a raw data array and returns the shaped output:

App/Resources/UserResource.php
use Nyoze\Domain\Resource;

class UserResource extends Resource
{
    protected function transform(array $data): array
    {
        unset($data['password']);
        return $data;
    }
}

You can reshape the data however you need — remove fields, rename keys, compute values:

App/Resources/CreditResource.php
use Nyoze\Domain\Resource;

class CreditResource extends Resource
{
    protected function transform(array $data): array
    {
        $plan      = $data['plan_credits'] ?? 0;
        $purchased = $data['purchased_credits'] ?? 0;

        return [
            'total_credits'     => $plan + $purchased,
            'plan_credits'      => $plan,
            'purchased_credits' => $purchased,
        ];
    }
}

Using Resources

make(array $data): array

Transforms a single record. Call it statically on your resource class:

$user = $repo->find('users', $userId);
$output = UserResource::make($user);
// Password field is removed from the output

collection(array $items): array

Transforms an array of records. Each item is passed through transform():

$users = $repo->all('users', ['active' => true]);
$output = UserResource::collection($users);
// Each user in the array has the password field removed

Using Resources in Actions

Resources are typically used in actions to shape the response data:

use Nyoze\Domain\ActionContext;
use Nyoze\Domain\Result;

class UserProfileAction
{
    public function __invoke(ActionContext $ctx): Result
    {
        $user = $ctx->repo()->find('users', $ctx->userId());

        if (!$user) {
            return Result::notFound('User not found');
        }

        return Result::ok(UserResource::make($user));
    }
}
class LoginAction
{
    public function __invoke(ActionContext $ctx): Result
    {
        // ... authentication logic ...

        return Result::ok([
            'token' => TokenHelper::generate($user['id']),
            'user'  => UserResource::make($user),
        ]);
    }
}

Resources keep your actions focused on business logic while ensuring consistent, clean output across your API.


Rules

Rules are named validation checks that enforce domain consistency on entities. They run automatically as invariants during the action pipeline, ensuring entity state remains coherent after every action. Rules should not duplicate simple field validations like required() — fields protect data presence and format, while rules protect domain logic.

Fields vs Invariants: Fields protect the minimum shape of data (presence, type, format). Invariants protect the final coherence of the entity. If a rule only checks that a field is present, it belongs in the field definition with ->required(). If a rule checks that the entity cannot end an action in a contradictory state, it belongs in an invariant.

Rule Abstract Class

The Nyoze\Domain\Rule class requires two methods:

namespace Nyoze\Domain;

abstract class Rule
{
    abstract public function check(array $data): bool;
    abstract public function message(): string;
}
  • check(array $data): bool — Receives the entity data as an associative array. Returns true if the data is valid, false if the invariant is violated.
  • message(): string — Returns the error message shown when the check fails.

Creating a Rule

Extend Rule and implement both methods. Good invariants protect domain coherence — they check relationships between fields or enforce business constraints that go beyond simple presence:

App/Rules/PublishedProjectHasSections.php
use Nyoze\Domain\Rule;

class PublishedProjectHasSections extends Rule
{
    public function check(array $data): bool
    {
        if ($data['status'] !== 'published') return true;
        return ($data['section_count'] ?? 0) > 0;
    }

    public function message(): string
    {
        return 'Published projects must have at least one section';
    }
}
App/Rules/ActiveProjectMustHaveOwner.php
use Nyoze\Domain\Rule;

class ActiveProjectMustHaveOwner extends Rule
{
    public function check(array $data): bool
    {
        if (($data['status'] ?? '') !== 'active') return true;
        return !empty($data['id_user']);
    }

    public function message(): string
    {
        return 'Active projects must have an owner';
    }
}

Attaching Rules as Invariants

Rules are attached to entities using the ->invariant() method. Pass the rule class name. Note that required() stays in the fields — the invariant protects a domain-level constraint:

use App\Rules\PublishedProjectHasSections;
use Nyoze\Domain\Entity;
use Nyoze\Domain\EntityAction;
use Nyoze\Domain\EntityDefinition;
use Nyoze\Domain\Field;

class ProjectEntity extends EntityDefinition
{
    public function name(): string { return 'projects'; }

    public function define(Entity $entity): void
    {
        $entity
            ->auth()
            ->ownedBy('id_user')
            ->fields(
                Field::ref('id_user', 'users')->required(),
                Field::string('title')->required(),
                Field::string('status')->default('draft'),
            )
            ->can(EntityAction::all())
            ->invariant(PublishedProjectHasSections::class);
    }
}

When a Rule class is passed to ->invariant(), the framework automatically extracts the message from the rule's message() method. The title field uses ->required() for presence validation — the invariant handles a different concern: ensuring published projects have sections.

Inline Invariants

For simple checks, you can use a closure instead of a Rule class. Pass the closure and a message string. Even inline, invariants should protect domain coherence — not duplicate field-level required() checks:

// Good: protects domain coherence (end date must be after start date)
$entity->invariant(
    fn(array $data) => !$data['start_date'] || !$data['end_date'] || $data['end_date'] >= $data['start_date'],
    'End date cannot be before start date'
);

This is equivalent to creating a Rule class, but more concise for one-off validations.

How Invariants Execute

Invariants run as part of the pipeline after the action handler executes. The execution order is:

  1. Before hooks
  2. Action handler
  3. Invariants ← rules are checked here
  4. When hooks
  5. After hooks

If any invariant fails (returns false), the pipeline returns a Result::invalid() response with the rule's message and a 422 HTTP status code. No further hooks execute after an invariant failure.

Transaction safety: When an invariant fails during a custom action, any database writes performed by the handler are automatically rolled back. The system guarantees that invariant failure never leaves the database in a partially mutated state. For CRUD operations, invariants are checked before the write occurs, so no rollback is needed.

// When PublishedProjectHasSections fails:
{
    "success": false,
    "error": "Published projects must have at least one section"
}
// HTTP 422 Unprocessable Entity

When to Use Rules vs Inline Checks

Use a Rule class when:

  • The validation is reused across multiple entities
  • The check has a clear, descriptive name
  • You want the validation to be testable in isolation

Use an inline closure when:

  • The check is specific to one entity
  • The logic is a simple one-liner
  • You don't need to reuse it

Both approaches produce the same runtime behavior — the choice is about code organization.


Hooks

Hooks let you run logic before or after actions, or react to specific field values. They integrate into the pipeline that executes every action in Nyoze.

Hook Types

Nyoze provides three hook methods on the Entity class:

MethodWhen It RunsSignature
before(string $event, Closure|string $handler)Before the action handler(mixed $data, ActionContext $ctx): mixed
after(string $event, Closure|string $handler)After the action handler and invariants(mixed $data, ActionContext $ctx): mixed
when(string $field, mixed $value, Closure|string $handler)When a field matches a specific value in the result(array $data, ActionContext $ctx): mixed

All three return self for chaining.

Before Hooks

before(string $event, Closure|string $handler): self

Runs before the named action executes. The handler receives the current record data and the ActionContext. It can:

  • Modify data — return an array to replace the record data passed to the action.
  • Short-circuit — return a Result to stop the pipeline and return immediately.
  • Pass through — return nothing (or null) to continue without changes.
$entity->before('create', function(mixed $data, ActionContext $ctx) {
    // Add a timestamp before the record is created
    $data['created_at'] = $ctx->now();
    return $data;
});
$entity->before('delete', function(mixed $data, ActionContext $ctx) {
    // Prevent deletion of admin users
    if (($data['role'] ?? '') === 'admin') {
        return Result::forbidden('Cannot delete admin users');
    }
});

You can register multiple before hooks for the same event. They run in the order they were registered. If a before hook modifies the data (returns an array), the updated data is passed to subsequent hooks and the action handler.

After Hooks

after(string $event, Closure|string $handler): self

Runs after the action handler completes and invariants pass. The handler receives the action result data and the ActionContext. After hooks are for side effects — logging, notifications, cache invalidation.

$entity->after('register', function(mixed $data, ActionContext $ctx) {
    // Send a welcome email after registration
    // $data contains the result from the action
});
$entity->after('delete', function(mixed $data, ActionContext $ctx) {
    // Clean up related resources after deletion
});

After hooks do not affect the action result. Their return values are ignored.

When Hooks

when(string $field, mixed $value, Closure|string $handler): self

Fires when a specific field in the action result matches a specific value. This is useful for reacting to state transitions.

$entity->when('status', 'published', function(array $data, ActionContext $ctx) {
    // Notify subscribers when a project is published
});

$entity->when('status', 'archived', function(array $data, ActionContext $ctx) {
    // Clean up resources when a project is archived
});

When hooks only fire if the action result is an array and the specified field exists with the matching value.

Using Class Handlers

Instead of closures, you can pass a class name as the handler. The class must implement __invoke:

class AddTimestamp
{
    public function __invoke(mixed $data, ActionContext $ctx): array
    {
        $data['updated_at'] = $ctx->now();
        return $data;
    }
}

$entity->before('update', AddTimestamp::class);

This is useful when hook logic is complex or reused across entities.

Pipeline Execution Order

Every action in Nyoze runs through a pipeline. The execution order depends on whether the action is a custom action (via Pipeline::run) or a built-in CRUD operation (via HttpEngine):

Custom Actions (via Pipeline::run)

1. Before hooks      → Run in registration order
2. BEGIN TRANSACTION
3. Action handler    → The __invoke method of the action class
4. Invariants        → All registered Rule checks
5. COMMIT
6. When hooks        → Triggered by field values in the result
7. After hooks       → Run in registration order

CRUD Operations (via HttpEngine)

1. Before hooks      → Run in registration order
2. Invariants        → All registered Rule checks
3. BEGIN TRANSACTION
4. Write operation   → The actual database write
5. After hooks       → Run in registration order
6. COMMIT

Note: when hooks and after hooks only execute after the transaction commits and only if the handler returns a successful result.

Step-by-step flow (custom actions)

  1. Before hooks for the action name run first. If any hook returns a Result, the pipeline stops and returns that result. If a hook returns an array, the data is updated for subsequent hooks and the action.
  2. A transaction begins automatically.
  3. Action handler executes with the (possibly modified) ActionContext. It returns a Result.
  4. Invariants are checked against the entity data. If any invariant fails, the transaction is rolled back and the pipeline returns Result::invalid() with the rule's message (HTTP 422). No further hooks run.
  5. The transaction is committed.
  6. When hooks fire if the action result contains data (an array) and any registered field/value pairs match.
  7. After hooks for the action name run last. They receive the action result data and are used for side effects.

Failure scenarios

  • If a before hook returns a Result with success: false, the action handler never runs.
  • If the action handler returns a failed Result, invariants still do not run on empty data, but when/after hooks may still execute depending on the result data.
  • If an invariant fails, when hooks and after hooks do not run.

Nested Dispatch (via $ctx->dispatch())

before hookshandlerinvariants → [queue when/after hooks] → return Result

Nested actions do NOT open or commit transactions — they share the root transaction. When hooks and after hooks are queued and only execute after the root transaction commits successfully.

Hooks in Nested Dispatch

When an action is dispatched via $ctx->dispatch():

  • Before hooks execute immediately at each level
  • When hooks and after hooks are queued, not executed immediately
  • Queued hooks flush only after the root transaction commits
  • If the root transaction rolls back, all queued hooks are discarded

This ensures side effects (emails, notifications, audit logs) never execute on inconsistent state.

Complete Example

App/Entities/OrdersEntity.php
use Nyoze\Domain\Entity;
use Nyoze\Domain\EntityDefinition;
use Nyoze\Domain\Field;
use Nyoze\Domain\ActionContext;
use Nyoze\Domain\Result;

class OrdersEntity extends EntityDefinition
{
    public function name(): string { return 'orders'; }

    public function define(Entity $entity): void
    {
        $entity
            ->auth()
            ->ownedBy('id_user')
            ->fields(
                Field::ref('id_user', 'users')->required(),
                Field::string('status')->default('pending'),
                Field::money('total')->required(),
                Field::datetime('created_at')->defaultNow(),
            )
            ->can('place', PlaceOrderAction::class)->post()
            ->can('cancel', CancelOrderAction::class)->post()
            ->before('place', function(mixed $data, ActionContext $ctx) {
                $data['created_at'] = $ctx->now();
                return $data;
            })
            ->after('place', function(mixed $data, ActionContext $ctx) {
                // Send order confirmation
            })
            ->when('status', 'shipped', function(array $data, ActionContext $ctx) {
                // Notify customer of shipment
            })
            ->when('status', 'cancelled', function(array $data, ActionContext $ctx) {
                // Process refund
            })
            ->invariant(fn(array $d) => ($d['total'] ?? 0) > 0, 'Order total must be positive');
    }
}

Cross-Entity Dispatch

When an action needs to invoke logic on another entity, it must use $ctx->dispatch() instead of accessing the repository directly. Direct repository access bypasses the pipeline — invariants won't be checked, hooks won't execute, and traceability is lost.

Basic Usage

class RegisterAction
{
    public function __invoke(ActionContext $ctx): Result
    {
        $user = $ctx->repo()->save('users', [...]);

        // Dispatch through the pipeline — not a direct repo call
        $result = $ctx->dispatch('credits', 'initialize', [
            'id_user' => $user['id'],
        ]);

        if (!$result->success) {
            return $result;
        }

        return Result::created([...]);
    }
}

Transaction Behavior

All dispatched actions share the same database transaction. Only the root action (depth=0) controls BEGIN/COMMIT/ROLLBACK.

  • If any nested action fails, the failure propagates up
  • The root action sees the failure and the transaction rolls back
  • All writes from all levels are reverted — no partial state

Hook Behavior in Nested Dispatch

Hook typeBehaviorWhen it runs
beforeImmediateBefore handler at each level
invariantImmediateAfter handler at each level
whenQueuedAfter root transaction commits
afterQueuedAfter root transaction commits (success only)

Execution Flow Example

subscriptions.manage (depth=0)
├─ BEGIN TRANSACTION
├─ handler
│   ├─ dispatch subscriptions.create (depth=1)
│   │   ├─ before hooks
│   │   ├─ handler (writes to user_subscriptions)
│   │   ├─ invariants checked
│   │   └─ when/after hooks → QUEUED
│   ├─ dispatch credits.updatePlanCredits (depth=1)
│   │   ├─ before hooks
│   │   ├─ handler (writes to credits)
│   │   ├─ invariants checked
│   │   └─ when/after hooks → QUEUED
│   └─ returns Result::ok
├─ invariants checked
├─ COMMIT
├─ FLUSH queued when hooks
└─ FLUSH queued after hooks

Safety Protections

  • Recursion detection: If A dispatches to B which dispatches back to A, a RecursiveDispatchException is thrown
  • Max depth: Dispatch chains are limited to 10 levels. Exceeding this throws MaxDepthExceededException
  • Correlation ID: Every dispatch chain shares a unique correlation ID accessible via $ctx->execution()->correlationId()

Call Stack Inspection

$ctx->execution()->callStack();
// [
//   ['entity' => 'subscriptions', 'action' => 'manage',           'depth' => 0],
//   ['entity' => 'subscriptions', 'action' => 'create',           'depth' => 1],
//   ['entity' => 'credits',       'action' => 'updatePlanCredits', 'depth' => 2],
// ]

Exceptions

RecursiveDispatchException

Thrown when a recursive dispatch cycle is detected (e.g., A → B → A). Contains the call stack and correlation ID.

MaxDepthExceededException

Thrown when the dispatch chain exceeds the maximum depth (default: 10). Contains the call stack and correlation ID.

NyozeDispatchException

Rich exception carrying full dispatch context. Provides a toConsistencyReport() method that returns:

[
    'correlation_id' => 'a1b2c3d4',
    'root'           => 'subscriptions.manage',
    'failed'         => 'credits.initialize',
    'stage'          => 'invariants',
    'rolled_back'    => true,
    'call_chain'     => '└── subscriptions.manage\n  └── credits.initialize ❌',
]

ExecutionContext

The ExecutionContext tracks the full lifecycle of a dispatch chain. It is created automatically by the framework for each HTTP request and shared across all nested dispatches.

Available Methods

MethodReturnsDescription
correlationId()stringUnique ID for this request chain
depth()intCurrent nesting depth (0 = not yet in any action)
callStack()arrayArray of ['entity', 'action', 'depth'] frames
rootEntity()stringName of the root entity
rootAction()stringName of the root action
currentEntity()?stringName of the currently executing entity
currentAction()?stringName of the currently executing action
parentEntity()?stringName of the parent entity (null if root)
parentAction()?stringName of the parent action (null if root)
isRoot()boolWhether the current execution is the root action
formatCallStack()stringHuman-readable call chain string

Database & Providers

Nyoze uses a provider-based architecture for database schema generation. Providers translate entity definitions into SQL specific to each database engine. Configuration is done via a fluent API on $app->database().

Configuration

MySQL Shortcut

The fastest way to configure MySQL:

index.php
$kernel = Kernel::load(function (App $app) {
    $app->database()->mysql([
        'host'     => '127.0.0.1',
        'database' => 'minha_app',
        'user'     => 'root',
        'pass'     => 'secret',
    ]);

    (new Context())->register($app);
});

The mysql() method automatically configures:

  • MySqlProvider as the SQL provider
  • PDO connection with the given parameters
  • Snowflake as the default ID strategy
  • Charset utf8mb4 (configurable)

Full Fluent API

For complete control:

use Nyoze\Data\Database\MySqlProvider;
use Nyoze\Data\Database\IdStrategy;

$app->database()
    ->provider(new MySqlProvider())
    ->connection([
        'dsn'  => 'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4',
        'user' => 'root',
        'pass' => 'secret',
    ])
    ->idStrategy(IdStrategy::Snowflake)
    ->nodeId(1);

DatabaseConfig Methods

MethodDescription
provider(DatabaseProviderInterface $p)Set the SQL provider
connection(array $params)Set PDO connection (dsn, user, pass, options)
idStrategy(IdStrategy $s)Set the global ID strategy
nodeId(int $id)Set nodeId for SnowflakeGenerator (0–1023)
mysql(array $params)Shortcut: MySqlProvider + connection + Snowflake
getProvider()Returns configured provider (or SqliteProvider fallback)
getIdStrategy()Returns configured strategy
getSnowflakeGenerator()Returns the SnowflakeGenerator (lazy-created)
createRepository()Creates PdoRepository with configured PDO

Providers

Providers implement DatabaseProviderInterface and translate entity definitions into SQL.

MySqlProvider

Generates MySQL-specific SQL. Type mapping:

Field TypeMySQL Type
Field::string()VARCHAR(255)
Field::text()TEXT
Field::integer()INT
Field::bigint()BIGINT
Field::decimal() / Field::money()DECIMAL(12,2)
Field::boolean()TINYINT(1)
Field::datetime()DATETIME
Field::date()DATE
Field::email() / Field::password()VARCHAR(255)
Field::json()JSON
Field::enum()VARCHAR(50)
Field::ref()BIGINT UNSIGNED

SqliteProvider

Reproduces the legacy Schema class behavior. Used as fallback when no provider is configured, ensuring backward compatibility.

Custom Provider

Implement DatabaseProviderInterface for other databases:

use Nyoze\Data\Database\DatabaseProviderInterface;
use Nyoze\Data\Database\TableDefinition;
use Nyoze\Data\Database\ColumnDefinition;
use Nyoze\Data\Database\IdStrategy;

class PostgresProvider implements DatabaseProviderInterface
{
    public function name(): string { return 'postgres'; }
    public function createTable(TableDefinition $table): string { /* ... */ }
    public function createAll(array $tables): string { /* ... */ }
    public function columnSql(ColumnDefinition $column): string { /* ... */ }
    public function idColumnSql(IdStrategy $strategy): string { /* ... */ }
}

ID Strategies

The IdStrategy enum defines how primary keys are generated:

StrategyMySQL ColumnDescription
SnowflakeBIGINT UNSIGNED NOT NULL PRIMARY KEY64-bit unique IDs (default)
AutoIncrementBIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEYDatabase auto-increment
UuidCHAR(36) NOT NULL PRIMARY KEYUUID v4
UlidCHAR(26) NOT NULL PRIMARY KEYSortable ULID

SnowflakeGenerator

Generates unique 64-bit IDs composed of timestamp (41 bits), nodeId (10 bits), and sequence (12 bits). Access it in actions via $ctx->ids():

class CreateOrderAction
{
    public function __invoke(ActionContext $ctx): Result
    {
        $orderId = $ctx->ids()->next();

        $order = $ctx->repo()->save('orders', [
            'id'      => $orderId,
            'id_user' => $ctx->userId(),
            'status'  => 'pending',
        ]);

        return Result::created($order);
    }
}

Auto-generation: When PdoRepository is configured with a SnowflakeGenerator (via $app->database()), IDs are generated automatically on insert when no id key is present in the data.

Distributed Environments

Configure a unique nodeId (0–1023) per server to prevent ID collisions:

$app->database()->mysql([...])->nodeId((int) getenv('SNOWFLAKE_NODE_ID') ?: 0);

Parsing IDs

use Nyoze\Data\Database\SnowflakeGenerator;

$parts = SnowflakeGenerator::parse($id);
// ['timestamp' => 1700000123456, 'nodeId' => 1, 'sequence' => 42]

Backward Compatibility

Existing code using Schema::createTable() and $app->useRepository() continues to work without changes. The Schema class delegates internally to SchemaBuilder with SqliteProvider as fallback.


Migrations

Migrations are versioned SQL files generated from entity definitions. Nyoze tracks which migrations have been executed in the Nyoze_migrations table, ensuring each runs only once.

Generating Migrations

terminal
php Nyoze make:migration

Output:

Created: 2025_01_15_143022_users.sql
Created: 2025_01_15_143022_projects.sql
Created: 2025_01_15_143022_sections.sql

The command reads all registered entities, skips virtual ones, uses the configured provider to generate SQL, and saves each file to database/migrations/.

File Format

database/migrations/
├── 2025_01_15_143022_users.sql
├── 2025_01_15_143022_users_rollback.sql      (optional)
├── 2025_01_15_143022_projects.sql
└── 2025_01_15_143022_sections.sql

Naming: {YYYY_MM_DD_HHmmss}_{entity_name}.sql

Generated SQL Example

2025_01_15_143022_users.sql
CREATE TABLE IF NOT EXISTS users (
    id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    password VARCHAR(255),
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

Running Migrations

terminal
php Nyoze migrate

Output:

✓ 2025_01_15_143022_users.sql
✓ 2025_01_15_143022_projects.sql
✓ 2025_01_15_143022_sections.sql

The MigrationRunner:

  1. Creates Nyoze_migrations table if it doesn't exist
  2. Scans database/migrations/ for .sql files (excluding _rollback.sql)
  3. Compares with already-executed migrations
  4. Executes pending migrations in chronological order
  5. Records each successful migration
  6. Stops on first failure

Rollback

terminal
php Nyoze migrate:rollback

Reverts the last executed migration. Looks for a corresponding _rollback.sql file:

2025_01_15_143022_users_rollback.sql
DROP TABLE IF EXISTS users;

If no rollback file exists, the command reports that manual rollback is required.

Schema Dump

Inspect the full schema without running migrations:

terminal
# Print to terminal
php Nyoze schema:dump

# Save to file
php Nyoze schema:dump --file

The --file flag saves to database/schema.sql.


CLI

Nyoze includes a built-in CLI for managing entities, schema, and migrations from the terminal.

Installation

The CLI is available via Composer's bin mechanism:

terminal
php vendor/bin/Nyoze

Or create a project-level bin/Nyoze that bootstraps your app and runs the Console.

Available Commands

CommandDescription
entitiesList all registered entities
schemaGenerate SQL schema (legacy SQLite-compatible)
make:migrationGenerate migration files from entities
schema:dumpDump full SQL schema using configured provider
migrateRun pending migrations
migrate:rollbackRollback last migration

Usage Examples

terminal
# List entities
php Nyoze entities

# Generate migrations from entities
php Nyoze make:migration

# Run pending migrations
php Nyoze migrate

# Rollback last migration
php Nyoze migrate:rollback

# Dump schema to terminal
php Nyoze schema:dump

# Dump schema to file
php Nyoze schema:dump --file

Bootstrap File

The CLI loads a bootstrap.php from the project root (if it exists) to configure the app:

bootstrap.php
<?php
use App\Context;
use Nyoze\Core\App;

return function (App $app) {
    $app->database()->mysql([
        'host'     => getenv('DB_HOST') ?: '127.0.0.1',
        'database' => getenv('DB_DATABASE') ?: 'minha_app',
        'user'     => getenv('DB_USER') ?: 'root',
        'pass'     => getenv('DB_PASS') ?: '',
    ]);

    (new Context())->register($app);
};

Troubleshooting

ErrorSolution
Could not find autoloaderRun composer install in the project root
No entities registeredEnsure bootstrap.php calls (new Context())->register($app)
No database connection configuredConfigure via $app->database()->mysql([...]) in bootstrap
No tables to generateCheck that entities don't call ->virtual()