Skip to main content

Models

Rhino models are standard Laravel Eloquent models enhanced with declarative static properties and traits that control how REST API endpoints are generated and behave. By configuring these properties directly on your model, Rhino automatically builds fully-featured API endpoints with filtering, sorting, searching, pagination, validation, and authorization -- all without writing controllers or routes.

RhinoModel Base Class​

The recommended way to create Rhino models is to extend RhinoModel — a convenience base class that pre-includes all the core traits you need:

app/Models/Post.php
use Rhino\Models\RhinoModel;

class Post extends RhinoModel
{
protected $fillable = ['title', 'content', 'status'];

public static $allowedFilters = ['status', 'user_id'];
public static $allowedSorts = ['created_at', 'title'];
public static $allowedSearch = ['title', 'content'];
}

RhinoModel extends Illuminate\Database\Eloquent\Model and includes these traits automatically:

TraitPurpose
HasFactoryLaravel factory support for testing
SoftDeletesTrash, restore, and force-delete endpoints
HasValidationRole-based validation rules
HidableColumnsDynamic column hiding from API responses
HasAutoScopeAuto-discovery of App\Models\Scopes\{Model}Scope classes

You no longer need to manually use these traits on every model.

Optional Traits​

These traits are not included in RhinoModel because they require additional database columns or configuration. Add them manually when needed:

app/Models/Post.php
use Rhino\Models\RhinoModel;
use Rhino\Traits\HasAuditTrail;
use Rhino\Traits\BelongsToOrganization;

class Post extends RhinoModel
{
use HasAuditTrail, BelongsToOrganization;
// ...
}
TraitPurpose
HasAuditTrailAutomatic change logging to audit_logs table
HasUuidAuto-generated UUID on creation
BelongsToOrganizationMulti-tenant organization scoping
HasPermissionsPermission checking (User model only)

Customizing RhinoModel​

You can publish and customize the base class for your application:

terminal
php artisan vendor:publish --tag=rhino-model

This creates app/Models/RhinoModel.php in your project, which extends the package's base class. Add your own traits or configuration that should apply to all Rhino models:

app/Models/RhinoModel.php
use Rhino\Models\RhinoModel as BaseRhinoModel;

class RhinoModel extends BaseRhinoModel
{
use HasAuditTrail; // Now all models get audit trail by default
}
tip

You can still extend Illuminate\Database\Eloquent\Model directly and apply traits manually if you prefer full control. RhinoModel is a convenience, not a requirement.

Model Configuration Properties​

Below is a complete model example demonstrating all available static properties that Rhino recognizes:

app/Models/Post.php
<?php

namespace App\Models;

use Rhino\Models\RhinoModel;
use Rhino\Traits\HasAuditTrail;
use Rhino\Traits\BelongsToOrganization;

class Post extends RhinoModel
{
use HasAuditTrail, BelongsToOrganization;

protected $fillable = [
'title', 'content', 'status', 'user_id', 'category_id',
];

// ── Query Builder ────────────────────────────────────────────
public static $allowedFilters = ['status', 'user_id', 'category_id'];
public static $allowedSorts = ['created_at', 'title', 'updated_at'];
public static $defaultSort = '-created_at';
public static $allowedFields = ['id', 'title', 'content', 'status'];
public static $allowedIncludes = ['user', 'comments', 'tags'];
public static $allowedSearch = ['title', 'content'];

// ── Named Scopes ─────────────────────────────────────────────
public static $allowedScopes = ['availableForDrivers'];
public static $defaultScope = 'active';

// ── Pagination ───────────────────────────────────────────────
public static bool $paginationEnabled = true;
protected $perPage = 25;

// ── Middleware ────────────────────────────────────────────────
public static array $middleware = ['throttle:60,1'];
public static array $middlewareActions = [
'store' => ['verified'],
'destroy' => ['admin'],
];

// ── Route Exclusion ──────────────────────────────────────────
public static array $exceptActions = ['destroy']; // skip DELETE endpoint

// ── Route Key ────────────────────────────────────────────────
public static string $routeKey = 'hash_id'; // column matched by {id} on member routes

}

Property Reference​

PropertyTypeDescription
$fillablearrayStandard Eloquent mass-assignable fields. Rhino uses this to determine which fields can be set via POST and PUT/PATCH requests.
$allowedFiltersarrayFields available for query-string filtering via ?filter[field]=value. Only the fields listed here can be filtered on.
$allowedSortsarrayFields available for sorting via ?sort=field. Prefix with - for descending order (e.g., ?sort=-created_at).
$defaultSortstringThe sort applied when no ?sort parameter is provided. Use the - prefix for descending (e.g., '-created_at').
$allowedFieldsarrayFields that can be selected via sparse fieldsets (?fields[model]=field1,field2). Limits which columns are returned.
$allowedIncludesarrayRelationships that can be eager-loaded via ?include=relation. Must correspond to defined Eloquent relationships on the model.
$allowedSearcharrayFields searched when ?search=term is used. Rhino performs a case-insensitive LIKE search across all listed fields.
$allowedScopesarrayNamed query scopes the client may select via ?scope=name. Each must have a matching scope{Name}(Builder $query, ?Authenticatable $user) method. Non-whitelisted names return 403. See Querying — Named Scopes.
$defaultScopestringNamed scope applied automatically when no ?scope= is provided (e.g., 'active'). A listing convenience — not a security boundary; mandatory restrictions belong in a global scope. Requesting it by name is always allowed.
$paginationEnabledboolEnables or disables pagination for the index endpoint. Defaults to true.
$perPageintNumber of records per page when pagination is enabled. Standard Eloquent property.
$middlewarearrayMiddleware applied to all routes for this model.
$middlewareActionsarrayMiddleware applied to specific actions only. Keys are action names (index, show, store, update, destroy).
$exceptActionsarrayList of CRUD actions to exclude from route generation. Valid values: 'index', 'show', 'store', 'update', 'destroy'.
$routeKeystringColumn matched against the {id} URL segment on member routes (show, update, destroy, restore, force-delete). Falls back to the global route_key config, then the primary key. See Route Key.
tip

You only need to declare properties that differ from their defaults. For example, if you do not need filtering, simply omit $allowedFilters entirely.

Route Key​

By default, the {id} segment on member routes (show, update, destroy, restore, force-delete) is matched against the model's primary key. Set $routeKey to look the record up by a different column instead — for example, to serve records at hash URLs and keep incremental IDs out of your API:

app/Models/Job.php
class Job extends RhinoModel
{
public static string $routeKey = 'hash_id';
}
GET /api/jobs/f3a9c1   ->  WHERE hash_id = 'f3a9c1'

The route parameter itself is still literally {id} — only the lookup column changes.

Resolution order:

  1. $routeKey on the model
  2. The global 'route_key' option in config/rhino.php
  3. The model's primary key, via Eloquent's getRouteKeyName()

Setting the global option to null, '', or 'id' all mean "not set" — resolution falls through to getRouteKeyName(), so overriding getRouteKeyName() on a model works too, and custom $primaryKey names are honored. You can resolve the effective column for any model with Rhino::routeKeyName($model) on the facade.

A few behaviors to be aware of:

  • The route-key column and id are always kept in serialized output, regardless of policy whitelists — clients need both to build URLs.
  • When the requested resource is the Organization model itself, its identity check also uses the route key.
  • In Blueprint specs, set options: { route_key: hash_id } to emit the $routeKey static in the generated model and use hash URLs in generated tests.
warning

The route-key column must be unique and should be indexed — every member request queries it. If values are not unique, an arbitrary matching record is returned.

database/migrations/create_jobs_table.php
$table->string('hash_id')->unique();
Scope of the route key

The route key only changes the URL lookup on the five member endpoints. Foreign keys in request payloads, nested-operation ids and $N.id references, exists: validation, audit auditable_id, and multi-tenant organization resolution (organization_identifier_column) all remain primary-key based.

Known limitations
  • A record whose route-key value is literally trashed is unreachable — literal routes are registered before {id}.
  • Hiding the real id column (via $additionalHiddenColumns) is possible, but nested-operation results still emit the raw primary key.
  • Filtering by the route-key column requires declaring it in $allowedFilters, as usual.

Available Traits​

Rhino provides a collection of traits that add specific behaviors to your models. When using RhinoModel, the core traits (HasValidation, HidableColumns, HasAutoScope, SoftDeletes) are already included. The traits documented below can be added individually when needed.

HasValidation​

Adds declarative validation to your model via validateStore() and validateUpdate() methods that Rhino calls automatically during store and update actions.

Included in RhinoModel — no need to add manually.

Validation belongs in a request class

store and update are validated by App\Http\Requests\{Model}StoreRequest / {Model}UpdateRequest, which see the user, the organization, the route group and the record being updated. The model properties below are deprecated and will be removed in 5.0; they are used only for a model and action with no request class. See Validation.

Model properties:

PropertyTypeDescription
$validationRulesarrayFormat rules keyed by field name (e.g., ['title' => 'string|max:255']).
$validationRulesMessagesarrayCustom error messages, following the Laravel validation message format.

Which fields each role can create or update is controlled by the policy's permittedAttributesForCreate() and permittedAttributesForUpdate() methods. See Policies — Attribute Permissions.

app/Models/Post.php
use Rhino\Models\RhinoModel;

class Post extends RhinoModel
{
protected $validationRules = [
'title' => 'string|max:255',
'content' => 'string',
'status' => 'string|in:draft,published,archived',
];

protected $validationRulesMessages = [
'title.max' => 'Post title cannot exceed 255 characters.',
];
}
info

For a complete breakdown of validation behavior, including how store and update rules interact, see the Validation page.


HasPermissions​

Adds role-based permission checking to the User model. Rhino uses this trait to authorize API actions automatically when policies are in place.

Methods:

MethodDescription
hasPermission(string $permission, ?Organization $org)Returns true if the user has the given permission within the specified organization.
getRoleSlugForValidation($organization)Returns the user's role slug within an organization, used for role-based validation rules.
userRoles()Eloquent relationship to the user's role assignments.

Permission format: {resource_slug}.{action}

Permissions follow the pattern of the resource slug (the key in your config/rhino.php models array) combined with the CRUD action:

  • posts.index -- can list posts
  • posts.store -- can create posts
  • blogs.update -- can update blogs
  • posts.destroy -- can delete posts

Wildcard support:

  • * -- grants access to everything across all resources
  • posts.* -- grants access to all actions on posts
app/Models/User.php
use Rhino\Traits\HasPermissions;

class User extends RhinoModel
{
use HasPermissions;
}

// Check if a user can create posts within an organization
if ($user->hasPermission('posts.store', $organization)) {
// User can create posts
}

// Check for full access
if ($user->hasPermission('*', $organization)) {
// User has unrestricted access to everything
}

// Check for all post actions
if ($user->hasPermission('posts.*', $organization)) {
// User can index, show, store, update, and destroy posts
}

HasAuditTrail​

Automatically records changes to your model in an audit log. Rhino tracks creation, updates, deletion, force-deletion, and restoration events and stores the old and new values for each change.

Tracked events: created, updated, deleted, force_deleted, restored

Model properties:

PropertyTypeDefaultDescription
$auditExcludearray['password', 'remember_token']Fields excluded from audit log entries. Use this to prevent sensitive data from being recorded.
app/Models/User.php
use Rhino\Traits\HasAuditTrail;

class User extends RhinoModel
{
use HasAuditTrail;

// Exclude sensitive fields from audit logs
protected $auditExclude = ['password', 'remember_token', 'api_token'];
}

// Query the audit trail for any model instance
$logs = $post->auditLogs()->latest()->get();

// Each log entry contains:
// - event (created, updated, deleted, etc.)
// - old_values (previous state)
// - new_values (current state)
// - user_id (who made the change)
// - timestamps

The auditLogs() method is a polymorphic relationship, so it works identically on any model that uses the trait.

info

For full details on querying and managing audit logs, see the Audit Trail page.


HasUuid​

Automatically generates a UUID for the model when it is created. The trait hooks into Eloquent's creating event and fills the uuid column if it is empty.

app/Models/Invoice.php
use Rhino\Traits\HasUuid;

class Invoice extends RhinoModel
{
use HasUuid;

// No additional configuration needed.
// A UUID is generated and assigned to the `uuid` column on creation.
}
warning

Your database table must have a uuid column. Add it in your migration:

database/migrations/create_invoices_table.php
$table->uuid('uuid')->unique()->nullable();
tip

HasUuid generates the external-facing identifier; a route key is what makes it routable. Combine the two (public static string $routeKey = 'uuid';) to serve records at /api/invoices/{uuid} instead of exposing incremental IDs.


BelongsToOrganization​

Provides multi-tenant organization scoping. This trait automatically filters all queries to the current organization and sets the organization_id when creating new records.

Adds:

MemberTypeDescription
organization()BelongsTo relationshipLinks the model to its owning organization.
scopeForOrganization($query, $org)Query scopeManually scope a query to a specific organization.
Global scopeAutomaticAll queries are automatically filtered by organization_id.
Auto-set on createAutomaticorganization_id is filled from the current request context on creation.
app/Models/Post.php
use Rhino\Traits\BelongsToOrganization;

class Post extends RhinoModel
{
use BelongsToOrganization;

// All queries are now scoped to the current organization automatically.
// GET /api/acme-corp/posts -> only returns posts where organization_id matches acme-corp
}

Nested ownership (auto-detected):

Not every model has a direct organization_id column. Rhino auto-detects the path by introspecting BelongsTo relationships — just define your relationships and scoping works automatically.

app/Models/Comment.php
class Comment extends RhinoModel
{
use BelongsToOrganization;

// Comment → Post → Blog → Organization (auto-detected via BelongsTo chain)
public function post()
{
return $this->belongsTo(Post::class);
}
}

Rhino traverses Comment -> post -> blog to find the organization automatically. The chain can be up to 3 levels deep.

info

For a full explanation of multi-tenancy and organization scoping, see the Multi-Tenancy page.


HidableColumns​

Controls which columns are hidden from API responses. This trait provides multiple layers of column visibility control: base defaults, model-level configuration, and policy-based per-user hiding.

Layers of hidden columns (applied in order):

  1. Base hidden columns (always hidden): password, remember_token, created_at, updated_at, deleted_at, email_verified_at
  2. Model-level hidden columns via $additionalHiddenColumns: additional fields to always hide for this model
  3. Policy-level attribute permissions via permittedAttributesForShow() and hiddenAttributesForShow() on the model's policy: per-user dynamic visibility
app/Models/User.php
class User extends RhinoModel
{

// Always hide these columns from API responses (in addition to base defaults)
public static $additionalHiddenColumns = ['api_token', 'stripe_id'];
}
tip

Hidden columns are cached per user for performance. If you change visibility rules, the cache updates automatically on the next request cycle.

info

For policy-based field visibility (showing different fields to different users), see Policies — Attribute Permissions.

Computed Attributes with rhinoComputedAttributes()​

Override rhinoComputedAttributes() in your model to add virtual (computed) attributes to API responses. These attributes are not database columns — they are calculated at runtime and merged into the serialized output.

app/Models/Contract.php
class Contract extends RhinoModel
{
public function rhinoComputedAttributes(): array
{
return [
'days_until_expiry' => $this->expiry_date?->diffInDays(now()),
'risk_score' => $this->calculateRisk(),
];
}
}

The returned array is merged into the JSON response before policy filtering is applied. This means computed attributes are always subject to policy-level blacklist and whitelist — just like database columns. The controller calls asRhinoJson() automatically when rendering responses.

warning

Do not override asRhinoJson() directly. Use rhinoComputedAttributes() instead. Overriding asRhinoJson() with parent::asRhinoJson() + [...] would add attributes after policy filtering, bypassing hiddenAttributesForShow() and permittedAttributesForShow() — a security risk.

Computed attributes can be controlled per-role via policy:

app/Policies/ContractPolicy.php
class ContractPolicy extends ResourcePolicy
{
public function hiddenAttributesForShow(?Authenticatable $user): array
{
if ($this->hasRole($user, 'admin')) {
return [];
}

return ['risk_score']; // Only admins see the risk score
}
}

You can also use permittedAttributesForShow() to whitelist which attributes (including computed ones) each role can see.

Expensive values and aggregates have their own hooks

rhinoComputedAttributes() runs on every row of every read. For per-row values that cost a query, and for collection-wide aggregates such as active_users_count, use the opt-in and collection-level hooks instead — see Computed Attributes. Never write a custom controller just to return a count.


HasAutoScope​

Automatically applies a global scope to the model based on a naming convention. When this trait is used, Rhino looks for a scope class at App\Models\Scopes\{ModelName}Scope and applies it if found. No manual registration is needed.

app/Models/Post.php
class Post extends RhinoModel
{

// Automatically loads App\Models\Scopes\PostScope (if it exists)
}

Create the corresponding scope class:

app/Models/Scopes/PostScope.php
<?php

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class PostScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('is_visible', true);
}
}

With this in place, every query for Post automatically includes WHERE is_visible = true. This is useful for soft-visibility flags, status filtering, or any default constraint you want applied globally.

tip

The scope is only applied if the class exists. You can safely add the HasAutoScope trait to any model without creating the scope class until you need it.


ViewModelHelpers​

Adds utility methods for formatting data in API responses. Currently provides currency formatting with support for multiple international currencies.

Method: formatPrice(float $amount, CurrencyOption $currency): string

Supported currencies:

Enum ValueSymbolFormat Example
CurrencyOption::USD$$1,234.56
CurrencyOption::CADC$C$1,234.56
CurrencyOption::GBP££1,234.56
CurrencyOption::BRLR$R$1.234,56
CurrencyOption::EUR€€1.234,56
CurrencyOption::CHFCHFCHF1,234.56
app/Models/Product.php
use Rhino\Traits\ViewModelHelpers;
use Rhino\Enums\CurrencyOption;

class Product extends RhinoModel
{
use ViewModelHelpers;
}

$product->formatPrice(1234.56, CurrencyOption::USD); // "$1,234.56"
$product->formatPrice(1234.56, CurrencyOption::BRL); // "R$1.234,56"
$product->formatPrice(1234.56, CurrencyOption::EUR); // "€1.234,56"
$product->formatPrice(1234.56, CurrencyOption::GBP); // "£1,234.56"
info

Note that BRL and EUR use dot as the thousands separator and comma as the decimal separator, matching their regional conventions.

Complete Model Example​

Below is a full real-world model that combines multiple Rhino traits into a feature-rich API resource:

app/Models/BlogPost.php
<?php

namespace App\Models;

use Rhino\Models\RhinoModel;
use Rhino\Traits\HasAuditTrail;
use Rhino\Traits\HasUuid;
use Rhino\Traits\BelongsToOrganization;

class BlogPost extends RhinoModel
{
use HasAuditTrail, HasUuid, BelongsToOrganization;

protected $fillable = [
'title', 'slug', 'content', 'excerpt',
'status', 'featured_image', 'user_id',
'category_id', 'published_at',
];

protected $casts = [
'published_at' => 'datetime',
];

// ── Validation ───────────────────────────────────────────────
protected $validationRules = [
'title' => 'string|max:255',
'slug' => 'string|max:255',
'content' => 'string',
'excerpt' => 'string|max:500',
'status' => 'string|in:draft,published,archived',
];

// Field permissions (which roles can write which fields)
// are controlled by the PostPolicy — see Policies page.

// ── Audit Trail ──────────────────────────────────────────────
// No extra exclusions beyond the defaults (password, remember_token)
protected $auditExclude = [];

// ── Query Configuration ──────────────────────────────────────
public static $allowedFilters = ['status', 'user_id', 'category_id'];
public static $allowedSorts = ['created_at', 'title', 'published_at'];
public static $defaultSort = '-published_at';
public static $allowedIncludes = ['user', 'category', 'comments', 'tags'];
public static $allowedSearch = ['title', 'content', 'excerpt'];
public static $allowedFields = ['id', 'title', 'slug', 'excerpt', 'status', 'published_at'];

// ── Pagination ───────────────────────────────────────────────
public static bool $paginationEnabled = true;
protected $perPage = 20;

// ── Relationships ────────────────────────────────────────────
public function user()
{
return $this->belongsTo(User::class);
}

public function category()
{
return $this->belongsTo(Category::class);
}

public function comments()
{
return $this->hasMany(Comment::class);
}

public function tags()
{
return $this->belongsToMany(Tag::class);
}
}

This single model definition gives you:

  • REST endpoints for listing, showing, creating, updating, and soft-deleting blog posts
  • Filtering by status, user, and category (?filter[status]=published)
  • Sorting by creation date, title, or publish date (?sort=-published_at)
  • Full-text search across title, content, and excerpt (?search=laravel)
  • Eager loading of user, category, comments, and tags (?include=user,comments)
  • Sparse fieldsets to reduce payload size (?fields[blog_posts]=id,title,excerpt)
  • Pagination at 20 records per page
  • Validation with different required fields for creation vs. update
  • Audit logging of every change with before/after values
  • UUID generation for external-facing identifiers
  • Organization scoping for multi-tenant data isolation
  • Column hiding to keep sensitive fields out of API responses

Registration​

Models are registered in config/rhino.php. The key becomes the URL slug and the permission prefix:

config/rhino.php
'models' => [
'blog-posts' => \App\Models\BlogPost::class,
'comments' => \App\Models\Comment::class,
'categories' => \App\Models\Category::class,
'tags' => \App\Models\Tag::class,
],

With this configuration, Rhino generates routes such as:

GET    /api/{organization}/blog-posts
GET /api/{organization}/blog-posts/{id}
POST /api/{organization}/blog-posts
PUT /api/{organization}/blog-posts/{id}
DELETE /api/{organization}/blog-posts/{id}
warning

The model key (e.g., blog-posts) is used as the permission prefix. Make sure it matches what you use in your role permission definitions (e.g., blog-posts.store, blog-posts.index).