Search by

rokastudio / racin

kalco

A small, fast PHP 8.3+ framework for JSON APIs: Rails-style controllers and generators, ActiveRecord-style models, serializers, JWT auth, queues, mail, image uploads and WebSockets.

Package info

github.com/kalco/racin

pkg:composer/rokastudio/racin

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.2.3 2026-10-25 08:49 UTC

This package is auto-updated.

Last update: 2026-10-25 08:54:42 UTC


README

tests

A small, fast PHP framework for JSON APIs, by Roka Studio: Rails-style controllers and generators, ActiveRecord-style models with relations, serializers, JWT auth with roles/permissions, queues, mail, image uploads, translations and a WebSocket server.

  • PHP 8.3+ (tested on 8.3, 8.4 and 8.5), strict types throughout
  • Only dependencies: psr/container, psr/log (optional extensions: gd, redis, pdo_pgsql, intl)
  • API only: every response is JSON, every error is {"message": ..., "errors": ...}

Installation

composer require rokastudio/racin
vendor/bin/racin init --url=http://my-api.local
composer dump-autoload
composer require --dev phpunit/phpunit

init writes the starter files of an API (existing files are kept unless --force):

public/index.php, public/.htaccess front controller; point the web server's document root at public/
public/uploads/ uploaded images, with an .htaccess that stops scripts from running there
.env, .env.example settings, with a new APP_KEY
config/*.php app, auth, database, cache, cors, filesystems, logging, mail, queue, broadcasting
routes/api.php /v1/auth/* routes and the // racin:resources marker the generators use
app/Models/User.php, users migration users with a role (user, admin, ...; permissions in User::PERMISSIONS)
app/Controllers/ApiController.php base controller: token check, checkPermission(), uploads
app/Controllers/Api/V1/AuthController.php register, login, me, refresh, logout, logout-all
database/seeders/ DatabaseSeeder and AdminUserSeeder (first admin from ADMIN_EMAIL / ADMIN_PASSWORD)
tests/, phpunit.xml ApiTestCase on an in-memory SQLite database, and auth tests

It also adds the App\, Database\Seeders\ and Tests\ autoload entries to composer.json (options: --name="My API", --url=..., --db=my_api). Then set DB_* and ADMIN_* in .env and:

vendor/bin/racin migrate --seed                                    # users table + first admin
vendor/bin/racin g scaffold Post title:string{150} body:text cover:image
vendor/bin/racin migrate
vendor/bin/phpunit

POST /v1/auth/login with the admin's email and password returns token.access_token; send it as Authorization: Bearer <token> to create, update and delete posts (/v1/posts).

The console is vendor/bin/racin <command>, run in the project root (the examples below write php racin). Without init, the smallest application is public/index.php with (new Racin\Application(dirname(__DIR__)))->run(); and a routes/api.php; every config file is optional.

Development

git clone https://github.com/kalco/racin.git && cd racin
composer install
composer test

Redis tests use REDIS_HOST (default Redis) and are skipped when it is unreachable; the WebSocket test starts a real server process.

Request lifecycle

public/index.php → Application::run()
  → bootstrap: .env → config/*.php → error handlers → core services → providers
  → Http\Kernel: global middleware (config app.middleware)
      → Router: match route → route middleware → controller action
  → return value → JsonResponse (array / JsonSerializable / model / paginator), null → 204
  → any exception → Exceptions\Handler → JSON error (rendered inside global middleware, so CORS still applies)

Routing

Routes live in routes/*.php ($router and $app are in scope).

use App\Controllers\PostController;
use Racin\Routing\Router;

$router->get('/posts', [PostController::class, 'index'])->name('posts.index');
$router->get('/posts/{id:\d+}', [PostController::class, 'show']);   // regex constraint
$router->get('/tags/{slug?}', [TagController::class, 'show']);       // optional
$router->post('/posts', 'App\Controllers\PostController@store');     // "Class@method"
$router->match(['PUT', 'PATCH'], '/posts/{id}', [PostController::class, 'update']);
$router->get('/ping', fn () => ['pong' => true]);                    // closures (not cacheable)

$router->group(['prefix' => 'v1', 'middleware' => ['auth'], 'as' => 'v1.'], function (Router $router) {
    $router->delete('/posts/{id}', [PostController::class, 'destroy'])->middleware('throttle:10,1');
});

$router->path('v1.posts.show', ['id' => 5]); // "/v1/posts/5"

Resource routes, like Rails' resources :posts (the action names are Rails'):

$router->resource('posts', PostsController::class);
//   GET /posts index · POST /posts create · GET /posts/{id} show · PATCH|PUT /posts/{id} update · DELETE /posts/{id} destroy
$router->resource('posts.comments', CommentsController::class, only: ['index', 'create']);
//   /posts/{post_id}/comments (the parent id reaches actions and filters as $post_id)
$router->resource('photos', PhotosController::class, except: ['update'], middleware: 'throttle:30,1');

Attribute routes (register the classes with $router->controllers([...])):

use Racin\Routing\Attributes\{Get, Post, Prefix, Middleware};

#[Prefix('/posts', name: 'posts.')]
#[Middleware('auth')]
final class PostController
{
    #[Get('/{id:\d+}', name: 'show')]
    public function show(int $id): array { /* ... */ }

    #[Post('/', middleware: 'throttle:10,1')]
    public function store(Request $request): JsonResponse { /* ... */ }
}

Controller method parameters are resolved by name from route parameters (with int/float/bool conversion; a value that cannot be converted gives 404) and by type from the container (Request, services, ...). Constructors are autowired too.

Unmatched path → 404, path matched with the wrong method → 405 with Allow. HEAD uses GET routes.

Requests

$request->input('user.name', 'default');  // body (JSON or form) merged over query string, dot notation
$request->query('page');
$request->string('name');  $request->integer('page', 1);  $request->boolean('active');  $request->array('tags');
$request->only(['name', 'email']);  $request->except(['password']);  $request->has('x');  $request->filled('x');
$request->header('X-Api-Version');  $request->bearerToken();  $request->ip();  $request->path();
$request->file('avatar');            // UploadedFile: isValid(), mimeType(), move($dir)
$request->user();                    // set by the auth middleware
$data = $request->validate(['email' => 'required|email']);  // throws 422

A malformed JSON body gives 400. X-Forwarded-For/-Proto are only trusted from app.trusted_proxies.

Responses

Return arrays, models, paginators or anything JsonSerializable. For control:

return new JsonResponse(['data' => $post], 201, ['Location' => "/posts/{$post->id}"]);
return Response::noContent();
abort(403, 'Not your post.');

Rails-style controllers

Controllers that extend Racin\Http\Controller get before_action filters, strong parameters, render() with serializers and head(). Queries stay in models (scopes, relations); the controller only wires things together.

use Racin\Http\Attributes\{BeforeAction, SkipBeforeAction};

#[BeforeAction('authorizeRequest')]                       // inherited by every subclass
abstract class ApiController extends Racin\Http\Controller { /* authorizeRequest(), checkPermission() ... */ }

#[SkipBeforeAction('authorizeRequest', only: ['index'])]
final class UsersController extends ApiController
{
    private User $user;

    public function __construct()
    {
        // before_action ..., only: [...]; filters run in the order they are declared
        $this->beforeAction(fn () => $this->checkPermission('users', 'read'), except: ['index']);
        $this->beforeAction('setUser', only: ['show', 'update']);
        $this->beforeAction(fn () => $this->checkTenantAccess($this->user), only: ['show']);
    }

    public function index(): Response
    {
        $users = User::byTenant($this->currentUser()->tenant_id)->with('roles')->paginate(20, $this->page());

        return $this->render(json: $users, eachSerializer: UserSerializer::class, status: 'ok');
    }

    public function show(): Response
    {
        return $this->render(json: $this->user, serializer: UserSerializer::class);
    }

    public function update(): Response
    {
        $this->user->update($this->params()->require('user')->permit('name', 'email', ['tags' => []]));

        return $this->render(json: $this->user, serializer: UserSerializer::class);
    }

    protected function setUser(int $id): void      // route parameters by name, services by type
    {
        $this->user = User::findOrFail($id);
    }
}
  • Filters run before the action: class attributes (parent classes first), then those registered in the constructor. A filter stops the request by throwing (abort(403), findOrFail → 404, validation → 422) or by returning a Response. only: / except: limit them to actions; #[SkipBeforeAction] or $this->skipBeforeAction() turn an inherited one off. Filter methods may be protected or private; only public methods declared on the controller are routable actions.
  • render(json:, serializer:, eachSerializer:, status:, headers:, meta:, root:) wraps the value as {"data": ...}; a Paginator adds "meta". status takes Rails names ('created', 'unprocessable_entity', ...) or numbers. head('no_content') sends no body.
  • params()->require('user') answers 400 when the key is missing; permit() keeps only the listed keys: scalars by name, ['tags' => []] for a list of scalars, ['address' => ['city', 'zip']] for nested data.
  • $this->request, $this->currentUser() and $this->validate([...]) work in actions and filters.

Serializers

Like ActiveModel::Serializer: which attributes and relations go out, computed fields as methods.

final class OrderSerializer extends Racin\Http\Serializer
{
    protected array $attributes = ['id', 'number', 'status', 'total', 'item_count', 'created_at'];
    protected array $relations = ['user' => UserSerializer::class, 'items' => OrderItemSerializer::class];

    public function itemCount(Order $order): int          // computed attribute "item_count" (camelCase method)
    {
        return count($order->items);
    }
}

OrderSerializer::one($order);                // array (or null)
OrderSerializer::many($orders);              // relations eager loaded for the whole list (no N+1)
OrderSerializer::page($paginator);           // Paginator of arrays

$this->context holds what the caller passed (render(context: [...]); the current user is in $this->context['user']). Dates are output as ISO-8601. Override relationPaths() to preload relations that a computed attribute reads (e.g. ['user'] for a user_name field).

Files & images

config/filesystems.php defines disks; without it public → public/uploads, served at APP_URL/uploads, and local → storage/app.

$disk = app(Racin\Storage\StorageManager::class)->disk('public');
$disk->put('reports/a.csv', $csv);  $disk->get(...);  $disk->exists(...);  $disk->url('reports/a.csv');  $disk->delete([...]);
storage_url($path);                 // URL on the default disk (null for null)

$stored = app(Racin\Image\ImageUploader::class)->store($request->file('photo'), 'products/7');
// ['path' => 'products/7/<random>.webp', 'thumb_path' => '..._thumb.webp', 'width', 'height', 'size', 'mime']
app(Racin\Image\ImageUploader::class)->delete($stored['path']);   // image + thumbnail

Storage paths are relative to the disk root (.., drive letters and NUL bytes are rejected). The uploader decodes the file with GD (needs the gd extension), applies the EXIF orientation, rejects anything that is not JPEG/PNG/GIF/WebP or is larger than 40 megapixels, resizes it (images.max_width, max_height), re-encodes it (images.format: webp, jpeg or png) and makes a cropped thumbnail; metadata and anything appended to the file are dropped. Validation rules for uploads: image (a real raster image, never SVG), mimes:jpg,png (checks the detected type, not the file name), max:<kilobytes>, dimensions:min_width=100,max_height=4000. Multipart requests: $request->file('photo'), hasFile(), allFiles(); HTML forms can send PATCH/DELETE as POST with a _method field.

Middleware

final class EnsureOwner implements Racin\Http\Middleware
{
    // "owner:post" → $resource = 'post'
    public function handle(Request $request, Closure $next, string $resource = 'post'): Response
    {
        $ownerId = DB::table($resource . 's')->where('id', (int) $request->route('id'))->value('user_id');

        if ($ownerId === null || (int) $ownerId !== $request->user()?->id) {
            throw new Racin\Auth\AuthorizationException();
        }

        return $next($request);
    }
}

Register an alias in config/app.php → middleware_aliases ('owner' => EnsureOwner::class) and use 'owner' or 'owner:post' (parameters after : separated by ,). Built in:

Alias Class
auth Auth\Middleware\Authenticate Bearer JWT (not revoked) → $request->user(), else 401
role:admin,manager Auth\Middleware\RequireRole user has one of the roles, else 403
can:catalog.manage Auth\Middleware\RequirePermission user has every listed permission, else 403
throttle:60,1 Http\Middleware\ThrottleRequests 60 requests / 1 minute per user (or IP) per route → 429
cors Http\Middleware\HandleCors Configured in config/cors.php, handles preflight
locale Http\Middleware\SetLocale language from ?lang= / Accept-Language, sets Content-Language

Validation

$data = Validator::make($input, [
    'email'      => 'required|email|max:255|unique:users,email',
    'age'        => 'nullable|integer|between:13,130',
    'items'      => 'required|array|max:50',
    'items.*.id' => 'required|integer|exists:products,id',
    'code'       => ['required', 'regex:/^[A-Z]{3}-\d+$/', new MyRule()],
], ['email.unique' => 'That email is taken.'])->validate();

Fields are optional unless a required* rule is present; validation of a field stops at its first failure; validate() returns only fields that had rules.

Rules: required required_if required_unless required_with required_without present accepted sometimes nullable string integer numeric boolean array list email url ip ipv4 ipv6 uuid alpha alpha_num alpha_dash date date_format before after min max between size gt gte lt lte in not_in regex not_regex confirmed same different digits digits_between json starts_with ends_with unique exists file image mimetypes. Custom: implement Validation\Rule, pass a closure fn ($attribute, $value, $fail), or Validator::extend().

Database

use Racin\Database\DB;

DB::table('users')->where('active', 1)->where('age', '>=', 18)->orderByDesc('id')->limit(10)->get();
DB::table('users')->where(fn ($q) => $q->where('a', 1)->orWhere('b', 2))->whereIn('id', [1, 2])->count();
DB::table('posts as p')->join('users as u', 'u.id', '=', 'p.user_id')->select('p.title', 'u.name')->get();
DB::table('users')->paginate(perPage: 20, page: 2);     // {"data": [...], "meta": {...}}
DB::table('users')->insertGetId(['name' => 'Ana']);
DB::table('users')->where('id', 5)->update(['name' => 'B']);   // update/delete without where() throw
DB::table('users')->where('id', 5)->increment('logins');
DB::transaction(function () { /* nested calls use savepoints */ });
DB::connection('other')->select('SELECT * FROM t WHERE id = ?', [5]);

All values are bound parameters. Column/table names are validated and quoted; use DB::raw(), selectRaw(), whereRaw() for SQL expressions (never with user input). Drivers: MySQL/MariaDB, PostgreSQL (pgsql, needs pdo_pgsql), SQLite. where('name', 'ilike', '%ana%') is case-insensitive on every driver; lockForUpdate()->skipLocked() skips rows other transactions hold.

Models

final class Post extends Racin\Database\Model
{
    protected string $table = 'posts';            // default: plural snake_case class name
    protected array $fillable = ['title', 'body'];
    protected array $hidden = ['internal_notes'];
    protected array $casts = ['published' => 'bool', 'meta' => 'array', 'published_at' => 'datetime'];
}

$post = Post::create($data);               // only $fillable keys are assigned
$post = Post::findOrFail($id);             // 404 when missing
$posts = Post::where('published', 1)->latest()->paginate(20, $page);
$post->update(['title' => 'New']);         // only dirty columns are written; updated_at maintained
$post->delete();
Post::findBy('slug', 'hello');  Post::firstOrCreate(['slug' => 'x'], ['title' => 'X']);  Post::updateOrCreate([...], [...]);

Relations, scopes, validation and callbacks (ActiveRecord style):

final class Post extends Model
{
    protected array $rules = [                       // checked on every save → ValidationException (422)
        'title' => 'required|string|max:200',
        'slug' => 'required|alpha_dash|unique:posts,slug,{id}',   // {id} = this record, ignored on update
        'user_id' => 'required|exists:users,id',
    ];

    public function user(): BelongsTo { return $this->belongsTo(User::class); }            // posts.user_id
    public function comments(): HasMany { return $this->hasMany(Comment::class)->latest(); } // comments.post_id
    public function cover(): HasOne { return $this->hasOne(Image::class); }
    public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class); }     // pivot "post_tag"

    public function scopePublished(QueryBuilder $query): void { $query->where('published', 1); }
    public function scopeBy(QueryBuilder $query, User $user): void { $query->where('user_id', $user->id); }

    protected function beforeValidation(): void { $this->slug ??= Str::slug($this->title); }
    protected function afterDelete(): void { /* remove files, ... */ }
}

$post->user->name;                                // lazy loaded once, then cached
$post->comments()->where('approved', 1)->count(); // a relation is also a query
Post::published()->by($user)->with('user', 'comments.author', 'tags')->paginate(20);  // eager: one query per relation
$post->load('tags');  $post->tags()->sync([1, 2]);  $post->tags()->attach(3);  $post->comments()->create([...]);

Callbacks: beforeValidation, beforeSave/afterSave, beforeCreate/afterCreate, beforeUpdate/afterUpdate, beforeDelete/afterDelete (a before* returning false cancels). Updates only validate the attributes the model has; save(validate: false) skips validation. Loaded relations are included in toArray() (unless listed in $hidden). Default table names are plural snake_case (Person → people, Category → categories).

Schema & migrations

Migrations are files in database/migrations (run in filename order) returning an anonymous class:

return new class extends Migration {
    public function up(Schema $schema): void
    {
        $schema->create('posts', function (Blueprint $table) {
            $table->id();                                              // BIGINT auto-increment primary key
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('title', 200);
            $table->text('body')->nullable();
            $table->decimal('price', 10, 2)->default(0);
            $table->boolean('published')->default(false)->index();
            $table->enum('state', ['draft', 'live'])->default('draft');
            $table->json('meta')->nullable();
            $table->timestamps();
            $table->unique(['user_id', 'title']);
        });
    }

    public function down(Schema $schema): void
    {
        $schema->dropIfExists('posts');
    }
};

The same blueprint produces correct DDL for MySQL/MariaDB, PostgreSQL and SQLite (booleans are stored as 0/1 everywhere, enums become CHECK constraints outside MySQL). $schema->table('posts', fn ($t) => ...) adds or drops columns and indexes. Migrations run inside a transaction on PostgreSQL and SQLite (MySQL commits DDL implicitly).

php racin make:migration create_posts_table --create=posts
php racin migrate [--seed]          php racin migrate:status
php racin migrate:rollback [--step=2]    php racin migrate:fresh [--seed]   (drops ALL tables)

All of them refuse to run with APP_ENV=production unless --force is given.

Seeders

Classes in database/seeders (namespace Database\Seeders) extending Racin\Database\Seeder; php racin db:seed runs DatabaseSeeder, --class=Name another one. $this->call(A::class, B::class) chains seeders; constructors get dependencies injected.

final class BlogSeeder extends Seeder
{
    public function run(): void
    {
        $this->seedFromFile(Blog::class, __DIR__ . '/data/blogs.json');                 // skipped while the table has rows
        $this->seedFromFile(Tag::class, __DIR__ . '/data/tags.csv', uniqueBy: 'slug');   // insert or update, re-runnable
    }
}

seedFromFile() reads a JSON list of objects or a CSV with a header row (empty cells are left out, so column defaults apply) and saves every row through the model (rules, casts, callbacks) in one transaction; an invalid row fails with its row number. php racin g seeder Blog [--count=N] writes the seeder and a data file with sample rows built from the model's $rules.

Auth

php racin key:generate sets APP_KEY, which signs HS256 JWTs.

$token = $app->make(Racin\Auth\Jwt::class)->encode(['sub' => $user->id, 'ver' => $user->token_version]);

Clients send Authorization: Bearer <token>; the auth middleware verifies signature/expiry, rejects revoked tokens and loads the user through auth.model (or a custom Auth\UserProvider set as auth.provider).

  • Revoking one token: $app->make(TokenBlacklist::class)->revoke($request->attribute('token')): its jti is kept in the cache until the token would have expired (use a shared cache, e.g. Redis, in production).
  • Revoking all of a user's tokens: issue tokens with a ver claim equal to the user's token_version column and increment that column; ModelUserProvider rejects tokens with an older version.
  • Roles & permissions: make the user model implement Racin\Auth\Authorizable (hasRole(), hasPermission()), then protect routes with role: / can: middleware.

Passwords: Racin\Support\Hash::make() / verify() (bcrypt, cost 12).

Cache, Redis & logging

$cache = $app->make(Racin\Cache\Cache::class);         // driver: file | redis | array
$stats = $cache->remember('stats', 300, fn () => DB::table('orders')->count());

$redis = $app->make(Racin\Redis\RedisFactory::class)->connection();   // phpredis, config database.redis

logger()->info('Order {id} paid', ['id' => $order->id]);   // storage/logs/racin-YYYY-MM-DD.log

The Redis store's flush() only deletes keys with its own prefix.

Queues

final class SendInvoice extends Racin\Queue\Job
{
    public int $tries = 5;                  // then it goes to failed_jobs
    public int|array $backoff = [10, 60];   // seconds between attempts

    public function __construct(public readonly int $orderId) {}   // scalars/arrays only, so pass ids

    public function queue(): string { return 'mail'; }

    public function handle(Mailer $mailer): void { /* dependencies injected */ }

    public function failed(Throwable $e): void { /* optional */ }
}

dispatch(new SendInvoice($order->id));            // or $app->make(QueueManager::class)->push($job, delaySeconds)

Drivers (config/queue.php): sync (runs immediately), database (jobs table, SKIP LOCKED for concurrent workers), redis. Payloads are JSON (no unserialize); jobs are rebuilt by passing the stored values to the constructor by name. A job reserved by a worker that crashed runs again after retry_after.

php racin queue:work [--queue=mail] [--connection=redis] [--once] [--stop-when-empty] [--max-jobs=100] [--memory=128]
php racin queue:failed | queue:retry <id|all> | queue:forget <id> | queue:flush

Mail

$mailer = $app->make(Racin\Mail\Mailer::class);
$message = (new Message())
    ->to('ana@example.com', 'Ana')
    ->subject('Нарачката е примена')                               // UTF-8 subjects/names are encoded
    ->html(Template::render(base_path('resources/mail/order.php'), ['order' => $order]))   // text part generated
    ->attach($pdfPath);

$mailer->send($message);     // now
$mailer->queue($message);    // via the queue (SendQueuedMail, 5 tries)

Transports (config/mail.php): smtp (STARTTLS tls or implicit ssl, AUTH LOGIN), log, array. Templates are plain PHP with $e($value) for escaping. php racin mail:test you@example.com sends a test.

Translations

lang/<locale>/<group>.php return arrays; __('messages.welcome', ['name' => 'Ана']), trans_choice('messages.items', $n) (one|many or {0} none|{1} one|[2,*] :count). The locale middleware picks the language per request from ?lang= or Accept-Language among app.locales, falling back to app.locale. Validation messages come from lang/<locale>/validation.php (rule keys, nested min.string etc., and attributes for field names); default HTTP error messages from http.php.

Real-time (WebSockets)

php racin websocket:serve runs an RFC 6455 server in plain PHP. The application sends events with

broadcast(['user.' . $order->user_id, 'admin.orders'], 'order.placed', ['id' => $order->id]);

(BROADCAST_DRIVER=websocket; also log, null, array). Events travel to the server's internal port (localhost, HMAC-signed with APP_KEY); if the server is down they are skipped with a log warning and retried after 15 s, so requests are never slowed down. Clients authenticate with {"event":"authenticate","token":"<jwt>"} and {"event":"subscribe","channel":"..."}; who may join which channel is defined in routes/channels.php, public.* channels are open to guests. Allowed browser origins: broadcasting.websocket.allowed_origins.

Console

php racin                    list commands
php racin serve --port=8000  development server
php racin route:list
php racin route:cache | route:clear | config:cache | config:clear | cache:clear
php racin key:generate [--force]
php racin migrate | migrate:status | migrate:rollback | migrate:fresh | make:migration | db:seed
php racin queue:work | queue:failed | queue:retry | queue:forget | queue:flush
php racin websocket:serve [--port=6001]
php racin mail:test <email>
php racin db:import file.sql
php racin make:controller Admin/Report | make:model Post | make:middleware EnsureOwner | make:command SendDigest

Generators (Rails' rails generate / rails destroy; g and d are aliases):

php racin g scaffold Post title:string{200}:unique body?:text price:decimal{10,2} published:boolean user:references cover:image
php racin g model Tag name:string:unique [--no-migration]
php racin g controller Reports index monthlySales          # + GET /v1/reports, /v1/reports/monthly-sales
php racin g migration AddSlugToPosts slug:string:unique    # also RemoveXFromY, CreateXTable
php racin g serializer Post                               # attributes read from the table
php racin g seeder Post --count=10                        # seeder + database/seeders/data/posts.json (rows that pass $rules)
php racin g postman Post                                  # CRUD folder (POST/GET/PUT/PATCH/DELETE) in postman/*.postman_collection.json
php racin d scaffold Post                                 # removes what g scaffold created (--force: also a migration that already ran)

scaffold writes the create-table migration, the model ($fillable, $casts, $rules from the field types, belongsTo for references, image cleanup on delete), the serializer (<field>_url and <field>_thumb_url for images), a resource controller in App\Controllers\Api\V1 (public index/show, writes need the "

.manage" permission, strong params, image upload), a test in tests/, and the $router->resource(...) line above // racin:resources in routes/api.php, a seeder with sample data (registered in DatabaseSeeder) and a Postman folder. Existing files are not overwritten without --force.

Field syntax: name[?]:type[{args}][:unique|:index], where ? means nullable. Types: string{len}, text, longtext, integer, bigint, smallint, tinyint, decimal{p,s}, float, boolean, date, datetime, json, enum{a,b,c}, uuid, references (user:references → user_id + foreign key + relation), image.

Custom commands extend Racin\Console\Command and are listed in config/app.php → commands.

Service providers

List providers in config/app.php → providers. register() binds services into the container ($this->app->singleton(...)), boot() runs after every provider registered.

Configuration

config/*.php files return arrays (config('app.name')); use env() only inside config files. Real environment variables override .env. php racin config:cache merges everything into one file for production (re-run after every change).