Installation
Requirements
- PHP >= 8.2
- Composer
Install via Composer
composer require pollauf/nyoze
Project Directory Structure
my-project/
├── App/
│ ├── Entities/
│ ├── Actions/
│ ├── Repositories/
│ ├── Resources/
│ ├── Rules/
│ └── Support/
├── vendor/
├── composer.json
├── index.php
└── .env
| Directory | Purpose |
|---|---|
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
{
"require": {
"php": ">=8.2",
"pollauf/nyoze": "*"
},
"autoload": {
"psr-4": {
"App\\\\": "App/"
}
}
}
Then run: composer dump-autoload
Quick Start
1. Create the Entry Point
<?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 applicationApp::useRepository()sets the data layerContext::register()loads entity definitions$kernel->app()->run()starts the HTTP engine
2. Create a Context Class
<?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
<?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
<?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:
class ListTasksAction
{
public function __invoke(ActionContext $ctx): Result
{
$tasks = $ctx->repo()->all('tasks');
return Result::ok($tasks);
}
}
5. HTTP Endpoints
| Method | Endpoint | Action |
|---|---|---|
POST | /tasks/create | CreateTaskAction |
GET | /tasks/list | ListTasksAction |
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 fluentEntityAPI.
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
| Method | HTTP Method | Route 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 above | All 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:
| Method | HTTP Verb | Typical Use |
|---|---|---|
get(): self | GET | Read operations |
post(): self | POST | Create or execute operations |
put(): self | PUT | Update operations |
delete(): self | DELETE | Delete 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:
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:
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
| Method | Signature | FieldType | Description |
|---|---|---|---|
id | id(string $name = 'id'): FieldBuilder | Id | Primary key identifier |
string | string(string $name): FieldBuilder | String | Short text (varchar) |
text | text(string $name): FieldBuilder | Text | Long text content |
integer | integer(string $name): FieldBuilder | Integer | Standard integer |
bigint | bigint(string $name): FieldBuilder | BigInt | Large integer |
decimal | decimal(string $name): FieldBuilder | Decimal | Decimal number |
boolean | boolean(string $name): FieldBuilder | Boolean | True/false value |
datetime | datetime(string $name): FieldBuilder | DateTime | Date and time |
date | date(string $name): FieldBuilder | Date | Date only |
email | email(string $name): FieldBuilder | Email | Email address |
password | password(string $name): FieldBuilder | Password | Password (hashed) |
money | money(string $name): FieldBuilder | Money | Monetary value |
json | json(string $name): FieldBuilder | Json | JSON data |
enum | enum(string $name, string $enumClass): FieldBuilder | Enum | PHP enum value |
ref | ref(string $name, string $entity): FieldBuilder | Ref | Foreign 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:
| Property | Type | Description |
|---|---|---|
name | string | Field name |
type | FieldType | Field type enum |
required | bool | Whether the field is required |
unique | bool | Whether the field must be unique |
hidden | bool | Whether the field is hidden from output |
nullable | bool | Whether the field accepts null |
default | mixed | Default value (if set) |
hasDefault | bool | Whether a default was explicitly set |
label | ?string | Human-readable label |
refEntity | ?string | Referenced entity name (for ref fields) |
enumClass | ?string | PHP enum class (for enum fields) |
maxLength | ?int | Maximum length constraint |
minLength | ?int | Minimum length constraint |
Complete 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
->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:
| Declaration | Method | Route | Handler |
|---|---|---|---|
EntityAction::list() | GET | /api/users | Built-in list |
EntityAction::create() | POST | /api/users | Built-in create |
EntityAction::get() | GET | /api/users/:id | Built-in get |
EntityAction::update() | PUT | /api/users/:id | Built-in update |
EntityAction::delete() | DELETE | /api/users/:id | Built-in delete |
->can('profile', ...)->get() | GET | /api/users/:id/profile | UserProfileAction |
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:
| Method | HTTP Status | Use Case |
|---|---|---|
Result::ok($data) | 200 | Successful operation |
Result::created($data) | 201 | Resource created |
Result::noContent() | 204 | Success with no body |
Result::fail($message) | 400 | Client error |
Result::unauthorized($message) | 401 | Not authenticated |
Result::forbidden($message) | 403 | Not authorized |
Result::notFound($message) | 404 | Resource not found |
Result::invalid($message) | 422 | Validation failure |
Result::redirect($url) | 302 | Redirect |
Result::json($data) | 200 | JSON response |
Complete Examples
Here's a login action that validates input, looks up a user, verifies the password, and returns a token:
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:
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:
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:
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:
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. Returnstrueif the data is valid,falseif 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:
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';
}
}
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:
- Before hooks
- Action handler
- Invariants ← rules are checked here
- When hooks
- 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:
| Method | When It Runs | Signature |
|---|---|---|
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
Resultto 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)
- 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. - A transaction begins automatically.
- Action handler executes with the (possibly modified)
ActionContext. It returns aResult. - 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. - The transaction is committed.
- When hooks fire if the action result contains data (an array) and any registered field/value pairs match.
- 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
Resultwithsuccess: 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 hooks → handler → invariants → [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
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 type | Behavior | When it runs |
|---|---|---|
before | Immediate | Before handler at each level |
invariant | Immediate | After handler at each level |
when | Queued | After root transaction commits |
after | Queued | After 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
RecursiveDispatchExceptionis 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
| Method | Returns | Description |
|---|---|---|
correlationId() | string | Unique ID for this request chain |
depth() | int | Current nesting depth (0 = not yet in any action) |
callStack() | array | Array of ['entity', 'action', 'depth'] frames |
rootEntity() | string | Name of the root entity |
rootAction() | string | Name of the root action |
currentEntity() | ?string | Name of the currently executing entity |
currentAction() | ?string | Name of the currently executing action |
parentEntity() | ?string | Name of the parent entity (null if root) |
parentAction() | ?string | Name of the parent action (null if root) |
isRoot() | bool | Whether the current execution is the root action |
formatCallStack() | string | Human-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:
$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:
MySqlProvideras the SQL provider- PDO connection with the given parameters
Snowflakeas 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
| Method | Description |
|---|---|
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 Type | MySQL 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:
| Strategy | MySQL Column | Description |
|---|---|---|
Snowflake | BIGINT UNSIGNED NOT NULL PRIMARY KEY | 64-bit unique IDs (default) |
AutoIncrement | BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY | Database auto-increment |
Uuid | CHAR(36) NOT NULL PRIMARY KEY | UUID v4 |
Ulid | CHAR(26) NOT NULL PRIMARY KEY | Sortable 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
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
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
php Nyoze migrate
Output:
✓ 2025_01_15_143022_users.sql
✓ 2025_01_15_143022_projects.sql
✓ 2025_01_15_143022_sections.sql
The MigrationRunner:
- Creates
Nyoze_migrationstable if it doesn't exist - Scans
database/migrations/for.sqlfiles (excluding_rollback.sql) - Compares with already-executed migrations
- Executes pending migrations in chronological order
- Records each successful migration
- Stops on first failure
Rollback
php Nyoze migrate:rollback
Reverts the last executed migration. Looks for a corresponding _rollback.sql file:
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:
# 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:
php vendor/bin/Nyoze
Or create a project-level bin/Nyoze that bootstraps your app and runs the Console.
Available Commands
| Command | Description |
|---|---|
entities | List all registered entities |
schema | Generate SQL schema (legacy SQLite-compatible) |
make:migration | Generate migration files from entities |
schema:dump | Dump full SQL schema using configured provider |
migrate | Run pending migrations |
migrate:rollback | Rollback last migration |
Usage Examples
# 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:
<?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
| Error | Solution |
|---|---|
Could not find autoloader | Run composer install in the project root |
No entities registered | Ensure bootstrap.php calls (new Context())->register($app) |
No database connection configured | Configure via $app->database()->mysql([...]) in bootstrap |
No tables to generate | Check that entities don't call ->virtual() |