Build and work with jonytonet/ptah — covering SOLID layered architecture, design tokens, scaffolding, BaseCrud configuration, Livewire conventions, optional modules, and tests. Use this skill whenever creating or modifying any entity, component or module in a Ptah-based project.
php artisan boost:add-skill jonytonet/ptah
Save this content to: .claude/skills/ptah-development/SKILL.md
# Ptah Development Skill
## When to use this skill
Use this skill when:
- Creating or modifying entities (Model, Service, Repository, DTO, Livewire)
- Configuring BaseCrud columns, filters, modal or row styles
- Activating or building optional modules (auth, menu, company, permissions)
- Writing Livewire components, view files or CSS
- Writing tests (unit or feature)
- Deciding where business logic, queries or validation belong
---
## Decision map — configure before you code
The most expensive mistake an agent makes here is **rebuilding something the
package already ships**. Read this table before writing any file; each row
names the ready-made path and what NOT to do.
| You need | Do this | Do NOT |
|---|---|---|
| A new entity + full CRUD screen | `php artisan ptah:forge Name --fields="..."` then configure via `ptah:config` or the visual editor (gear icon on the screen) | Hand-write a Livewire listing/form component |
| Columns, filters, badges, row styles, actions, modal fields on an existing CRUD | `ptah:config` flags or the visual editor — it is all JSON in `crud_configs` | Edit Blade views or create custom components |
| A listing of an existing table | A `crud_configs` row is enough — BaseCrud renders from config; no view, no component, no controller beyond the thin generated one | Build a table view by hand |
| Data access / business rules outside a CRUD screen | Extend `BaseRepository` / `BaseService` via contracts (see the `ptah-data-layer` skill — `getData(Request)` already does search/filter/sort/paginate, and the bases ship the CRUD method set: all/paginate/find/create/update/delete/destroy — there is no `restore`; soft-delete restore is done by BaseCrud) | Write Eloquent queries in Livewire/controllers |
| A REST API for an entity | `php artisan ptah:module api` once, then `ptah:forge Name --fields="..." --api` — controller with full Swagger `@OA\*` annotations, Create/Update API requests, versioned `Route::prefix('v1')` routes and the `BaseResponse` envelope, all generated and working | Hand-write API controllers, resources or response envelopes |
| Notify users when records change | CrudConfig editor → Notifications tab + `SendsCrudNotifications` trait on the model | Write observers/listeners that insert notifications |
| Permissions per screen / column | `permissionIdentifier` + column tag `colsPermission`, checked by the ptah Permissions module — or, with `ptah.permissions.driver = gate`, by Laravel's Gate (Spatie, policies) | if() checks scattered in views |
| A whole module (several related entities) | `php artisan ptah:blueprint spec.json --dry-run`, then without `--dry-run` — forge in FK order, migrate, config, menu, permissions, seed ([AgentTools.md](../../../../docs/AgentTools.md)) | Run a dozen commands by hand and get the parent/child order wrong |
| Import, change history, attachments, board or calendar on a screen | Gear icon → General → Features (or `importConfig`/`kanbanConfig`/`calendarConfig`); history and attachments also need `RecordsHistory`/`HasAttachments` on the model and `ptah:history:install`/`ptah:attachments:install` | Build upload screens, audit tables or calendars by hand |
| Dashboard numbers, or settings an admin changes | `config/ptah-dashboard.php` widgets; `config/ptah-settings.php` + `ptah_setting()` (`ptah:settings:install`) — docs/Dashboard.md, docs/Settings.md | A hand-made dashboard controller or a `settings` table |
| A new column on an existing entity | `php artisan ptah:field Entity add name:type[:modifiers]` — migration, `$fillable`/`$casts`, rules, DTO and crud config in one call | Edit five files and forget `$fillable` (the field is then silently not saved) |
| A screen that is genuinely not a CRUD | [CustomScreens.md](../../../../docs/CustomScreens.md): `<x-forge-*>` components + `--ptah-*` tokens only | Raw HTML with Tailwind palette colors |
### What BaseCrud already does (do not rebuild any of this)
Global search + per-column search · custom filters with operators · date-range
and quick-date filters · saved filters · sorting (table headers + card-view
sort bar) · pagination with per-page control · export XLSX/CSV/PDF + print ·
bulk actions (delete/restore/export + custom, permission-gated) · row styles
by condition · badges with value→color maps · relation columns via JOIN ·
SearchDropdown fields (model or service mode, masks, filters) · master-detail ·
group break with subtotals · totalizers · soft deletes + trash view + restore ·
audit fields (created/updated/deleted_by) · column-level permissions · ACL
integration · per-CRUD event notifications · responsive card layout (auto on
mobile) with its own sorting · per-user preferences (columns, widths, order,
view mode, density) · keyboard shortcuts · CSV/JSON config import/export ·
lifecycle hooks for custom logic around save/delete.
If the request maps to anything above, the answer is **configuration**, not
code. When in doubt: `php artisan ptah:config "App\Models\X" --list` shows
what a screen already has.
### Ask the project instead of reading it
Each of these answers in one call what would otherwise take several file
reads. Prefer them — they read the same truth the runtime reads.
| To know | Run |
|---|---|
| What exists (entities, fields, relations, screens, menu, TODOs) | `php artisan ptah:map` |
| What one screen has, before editing it | `php artisan ptah:screen Entity` — not the JSON |
| Why a screen is empty for a user | `php artisan ptah:why-empty Entity --as=<id>` |
| The exact syntax of a `--column` / `--filter` / `--style` / `--action` / `--join` / mask | `php artisan ptah:docs column` (or `filter`, `style`, …) |
| Whether every screen still works after a change | `php artisan ptah:check` (`--write` also round-trips a save, rolled back) |
| Why something broke | `php artisan ptah:last-error` — never `tail` the log |
| What an update of ptah needs in this project | `php artisan ptah:upgrade-check` |
| Whether a config JSON you wrote is right, BEFORE saving it | `php artisan ptah:config:doctor --file=x.json --model=Entity` |
| How a feature works (any subject) | `php artisan ptah:docs --search="column permission"` |
The same are MCP tools (Boost's server, or `php artisan mcp:start ptah`):
`ptah-map`, `ptah-screen`, `ptah-check`, `ptah-docs` (`search`),
`ptah-why-empty`, `ptah-last-error`, `ptah-upgrade-check`, `ptah-doctor`,
`ptah-validate-config`.
`ptah:forge … --factory` also writes a factory and a demo seeder from the
field types.
### Where to read more (token budget guide)
Read the SMALLEST document that answers the question — in this order:
| Question | Read |
|---|---|
| Any config flag / column type / option syntax | `php artisan ptah:docs <topic>`, then this skill's "Configuring BaseCrud" sections below |
| Full BaseCrud runtime behaviour | `docs/BaseCrud.md` |
| Every `ptah:*` command | `docs/Commands.md` |
| Repository/Service/DTO contracts | `ptah-data-layer` skill, then `docs/BaseLayer.md` |
| Custom screens, tokens, theming | `docs/CustomScreens.md` |
| Permissions/ACL | `docs/Permissions.md` |
| Notifications | `docs/Notifications.md` |
| What is known-broken or deliberate | `docs/KnownLimitations.md` |
---
## SOLID Architecture — Layer Rules (NEVER violate)
### Layer map
```
HTTP Request
│
▼
FormRequest → validates HTTP input only
│
▼
Controller / Livewire → calls Service via Contract; NO queries, NO business logic
│
▼
ServiceContract → interface in app/Contracts/Services/ (convention — forge does NOT generate one)
│
▼
Service → ALL business logic; calls RepositoryContract; throws domain exceptions
│
▼
RepositoryContract → interface in app/Contracts/Repositories/ (forge generates it as
app/Repositories/Contracts/{Entity}RepositoryInterface + the binding)
│
▼
Repository → ALL database access; Eloquent queries, filters, pagination
│
▼
Model → schema, casts, scopes, relationships, boot hooks; NO logic
│
▼
DTO → immutable value object; fromArray(); passed between layers
```
### Single Responsibility — what each layer owns
| Layer | Owns | Must NOT contain |
|---|---|---|
| Model | `$fillable`, `$casts`, scopes, relationships, `boot()` hooks | Business rules, DB queries in methods |
| DTO | `readonly` properties, `fromArray()`, `toArray()` | Persistence, validation |
| Repository | Eloquent queries, raw SQL, pagination, eager loads | Business rules, HTTP/session awareness |
| Service | Business rules, orchestration, events, domain exceptions | Eloquent queries, HTTP redirects |
| FormRequest | `rules()`, `authorize()`, `messages()` | Business logic |
| Livewire/Controller | Call Service, return view/response | Queries, business rules |
### Dependency Inversion — always inject Contracts
```php
// ✅ Correct
public function __construct(
private readonly ProductServiceContract $products,
private readonly CategoryRepositoryContract $categories,
) {}
// ❌ Wrong — never inject concrete classes
public function __construct(private readonly ProductService $products) {}
```
### Concrete example of correct layer separation
```php
// ❌ Anti-pattern: business logic leaking into Livewire
public function save(): void
{
$exists = Product::where('sku', $this->sku)->exists(); // query in Livewire ❌
if ($exists) { $this->addError('sku', 'Duplicado'); return; }
Product::create([...]); // direct create in Livewire ❌
}
// ✅ Correct: Livewire → Service (via Contract) → Repository
// Livewire:
public function save(): void
{
$this->validate();
try {
$this->products->create(ProductDTO::fromArray($this->only(['name','sku','price'])));
$this->showModal = false;
} catch (DuplicateSkuException $e) {
$this->addError('sku', $e->getMessage());
}
}
// Service:
public function create(ProductDTO $dto): Product
{
if ($this->repo->existsBySku($dto->sku)) {
throw new DuplicateSkuException("SKU {$dto->sku} already registered.");
}
return $this->repo->create($dto->toArray());
}
// Repository:
public function existsBySku(string $sku): bool
{
return Product::where('sku', $sku)->exists();
}
```
---
## Colors & Theming — the rule that keeps screens on-theme
Ptah has 6 per-user appearance axes (light tone, dark tone, accent, text
weight, density, font size). The user can switch the light tone to **papel**
(warm paper) or **nevoa** at any time, and every ptah screen follows. A screen
follows the theme **only** if every color in it resolves through a CSS custom
property the presets rewrite. A fixed Tailwind palette class does not.
**The failure this rule prevents, seen in production:** a screen built with
`bg-white` / `bg-light` stays literally white when the user switches the tone
to *papel* — it is the one white rectangle on a warm-paper page.
### The 3 layers, in order of preference
1. **`<x-forge-*>` component props** — zero theming work, always safe:
```blade
<x-forge-button color="primary">Salvar</x-forge-button>
<x-forge-button color="danger" flat>Excluir</x-forge-button>
<x-forge-alert type="success">Salvo!</x-forge-alert>
```
2. **Semantic Tailwind classes that are theme-safe** — safe because their
`--color-*` variable is rewritten by a preset (accent axis) or is a
deliberate constant (status colors mean the same thing in every theme):
`bg-primary` `text-primary` · `bg-success` `text-success` ·
`bg-danger` `text-danger` · `bg-warn` `text-warn`
3. **`var(--ptah-*)` tokens for any custom surface/text/border** — the full
contract (~30 tokens with usage notes) is
[CustomScreens.md §1](../../../../docs/CustomScreens.md); the ones you will
need constantly:
| Need | Token |
|---|---|
| Page-flush background (toolbar, filter panel) | `--ptah-canvas` |
| Card / button / modal surface | `--ptah-surface` |
| Elevated surface (dropdown menu) | `--ptah-surface-raised` |
| Recessed panel (modal body) | `--ptah-surface-sunken` |
| Hover tint | `--ptah-surface-hover` |
| Default / secondary / faint text | `--ptah-text` / `--ptah-text-secondary` / `--ptah-text-faint` |
| Border / stronger border | `--ptah-line` / `--ptah-line-strong` |
| Control height & font (density axis) | `--ptah-control-h` / `--ptah-control-fs` |
### Forbidden in any view or CSS you write
- `bg-white`, `bg-light`, `bg-dark`, `bg-slate-*`, `bg-gray-*`, `text-slate-*`
and every other fixed-palette class used for **surfaces or text** — they
ignore the tone presets.
- `dark:` Tailwind variants — dark mode here is `.ptah-dark` redefining the
SAME tokens; markup never branches on the mode.
- Hex/rgb literals in views or in per-view `<style>` blocks (which are
themselves forbidden — see CSS Architecture Rules).
```blade
{{-- ❌ stays white under the papel tone --}}
<div class="bg-white dark:bg-slate-800 border rounded-lg p-4">
{{-- ✅ follows every axis with zero extra work --}}
<div class="rounded-lg p-4 border"
style="background: var(--ptah-surface); border-color: var(--ptah-line);">
```
To find offenders in an existing project:
```bash
grep -rnE 'bg-(white|light|slate|gray)|dark:|#[0-9a-fA-F]{3,6}' resources/views --include='*.blade.php'
```
---
## Scaffolding New Entities
```bash
# Single entity (SoftDeletes is ON by default — pass --no-soft-deletes to disable)
php artisan ptah:forge Product \
--fields="name:string,sku:string,price:decimal(10,2),stock:integer,category_id:unsignedBigInteger,is_active:boolean"
# Sub-folder (large projects)
php artisan ptah:forge Inventory/ProductStock \
--fields="product_id:unsignedBigInteger,location:string,qty:integer"
# model key = 'Inventory/ProductStock'
# namespace = App\Models\Inventory\ProductStock
```
`--fields` types: `integer`, `bigInteger`, `mediumInteger`, `smallInteger`,
`tinyInteger`, their `unsigned*` forms, `foreignId` (constrained FK),
`string`, `char`, `text`, `longText`, `decimal`, `float`, `double`, `boolean`,
`date`, `datetime`, `timestamp`, `json`, `enum` (aliases: `int`, `bigint`,
`uint`, `ubigint`, `bool`, `foreign`, `jsonb`). **Any other type is refused**
(`FieldDefinition::TYPES`). Always state `decimal(p,s)` — a bare `decimal`
silently becomes `decimal(10,2)`. Modifiers: `:nullable`, `:unique`, `:index`,
`:default(val)`, `:label=Text`.
```bash
# With API (web + API in one command)
php artisan ptah:forge Catalog/Product \
--fields="name:string,price:decimal(10,2),category_id:unsignedBigInteger" \
--api
```
### Automatic menu
Every entity generated **automatically adds** a sidebar menu link to
`database/seeders/MenuRegistry.php` (needs `ptah:install` to have created it) —
under a group when the entity has a subfolder, as a flat root link when not:
```bash
# During scaffolding
php artisan ptah:forge Health/VaccinationType --fields="..."
# → Adds an entry to database/seeders/MenuRegistry.php
# After generating all entities, sync the menu:
php artisan ptah:menu-sync
# → Populates the 'menus' table with every link
```
**Automatic mappings:**
- Module `Health` → group "Saúde" (icon `bx bx-plus-medical`)
- Entity `VaccinationType` → link "Tipos de Vacina" (icon `bx bx-shield-plus`)
**Disabling the menu for one entity:**
```bash
php artisan ptah:forge Health/Test --fields="..." --no-menu
```
---
## Post-scaffold Checklist (after every ptah:forge)
After running `ptah:forge` and `php artisan migrate`, **always** perform these steps:
### 1. FK imports — only if `ptah:forge` reported a ⚠
The generator resolves each foreign key's `use` itself when exactly one class
of that name exists in `app/Models`, and its output says what it did:
```
Relationship imports:
✔ category_id → App\Models\Catalog\Category
⚠ supplier_id → Supplier not found in app/Models yet — generate it, then fix the TODO in the model
```
Only a `⚠` line needs you: the related model was not generated yet, or more
than one class has that name (the TODO lists them). **Generate entities in
dependency order** — `Category` before `Product` — and there is nothing to fix.
Never leave a `// TODO:` line in committed code.
### 2. Run Pint to format all generated files
```bash
./vendor/bin/pint
```
### 3. Run migrations
```bash
php artisan migrate
```
### 4. Clear views and config cache
```bash
php artisan view:clear
php artisan config:clear
```
---
## HasAuditFields — Automatic Audit Columns
Every model generated by `ptah:forge` automatically includes the `Ptah\Traits\HasAuditFields` trait and audit columns in its migration. All internal package models (Company, Department, Role, Menu, CrudConfig, PtahPage, PageObject, UserRole, RolePermission) also use this trait.
### What it does
| Column | Type | Filled when |
|---|---|---|
| `created_by` | `unsignedBigInteger` nullable | Eloquent `creating` event |
| `updated_by` | `unsignedBigInteger` nullable | Eloquent `creating` and `updating` events |
| `deleted_by` | `unsignedBigInteger` nullable | Eloquent `deleted` event (only when SoftDeletes is enabled — i.e. not `--no-soft-deletes`) |
### Critical rules for agents
```
✅ NEVER manually set created_by / updated_by in service, controller or Livewire
The HasAuditFields trait fills them automatically via Eloquent events.
BaseCrud::save() also injects them explicitly as a belt-and-suspenders safeguard.
✅ NEVER use ->whereIn()->delete() for bulk ops on models with HasAuditFields
Use ->each(fn($r) => $r->delete()) so Eloquent fires the `deleted` event per record
and deleted_by is stamped correctly on each row.
✅ deleted_by uses the `deleted` event (after soft-delete commits), NOT `deleting`
This prevents stamping deleted_by on a record whose soft-delete later fails.
✅ Guard is === null (not empty()) so user ID 0 is not treated as "unset".
```
### Available relationships
```php
$record->createdBy // BelongsTo → User (resolved via auth.providers.users.model)
$record->updatedBy // BelongsTo → User
$record->deletedBy // BelongsTo → User
```
### Required model setup
```php
use Ptah\Traits\HasAuditFields;
class Product extends Model
{
use HasAuditFields;
protected $fillable = [
// ... your fields ...
'created_by', 'updated_by', // always
'deleted_by', // only if model uses SoftDeletes
];
protected $casts = [
'created_by' => 'integer',
'updated_by' => 'integer',
'deleted_by' => 'integer', // only if SoftDeletes
];
}
```
`ptah:forge` generates all of the above automatically — no manual setup needed for scaffolded entities.
---
## CSS Architecture Rules
1. **Never** add `<style>` blocks inside view files
2. New CSS lives in `resources/css/ptah-components.css`, built on `--ptah-*` design
tokens (`--ptah-primary`, `--ptah-surface`, `--ptah-text-*`, …) — never a bare hex
literal. `forge-dashboard-layout.blade.php` still carries a legacy inline `<style>`
block for a shrinking set of chrome rules; it is being dismantled, not extended —
`LayoutStyleBaselineTest` fails the build if it gains a single color literal or rule
(see [KnownLimitations.md §6](../../../../docs/KnownLimitations.md#6-theming--partial-coverage-1260))
3. Dark mode always via a `.ptah-dark` ancestor
4. The 6 per-user appearance axes (light/dark tone, accent, text weight, density, font
size) are selected via `data-ptah-*` attributes on `<html>`, resolved by
`Ptah\Support\AppearancePresets` — see [CustomScreens.md](../../../../docs/CustomScreens.md)
```css
/* ✅ Inside resources/css/ptah-components.css — .ptah-dark redefines the SAME
token names in its own block, components never branch on .ptah-dark themselves */
:root { --ptah-surface: #ffffff; }
.ptah-dark { --ptah-surface: #1e293b; }
.my-component { background: var(--ptah-surface); }
```
---
## Configuring BaseCrud (CLI)
### Via Command Line
```bash
# Configure complete CRUD in one command
php artisan ptah:config "App\Models\Product" \
--column="id:number:label=ID:sortable:min_width=80px" \
--column="name:text:label=Nome:required:sortable" \
--column="price:number:label=Price:renderer=money:sortable" \
--column="is_active:select:label=Status:renderer=badge:badges=1|success|Ativo,0|danger|Inativo" \
--style="is_active:==:0:background:#FEF2F2;color:#B91C1C;" \
--style="stock:<:5:background:#FEFCE8;color:#A16207;" \
--filter="is_active:select:label=Ativos:operator==:options=1:Ativo,0:Inativo" \
--action="duplicate:livewire:duplicate(%id%):icon=bx-copy:color=info:confirm=true" \
--set="itemsPerPage=15"
# List current configuration
php artisan ptah:config "App\Models\Product" --list
# Export to JSON
php artisan ptah:config "App\Models\Product" --export=product-config.json
# Import from JSON
php artisan ptah:config "App\Models\Product" --import=product-config.json
# Reset to defaults
php artisan ptah:config "App\Models\Product" --reset
# Dry-run (show changes without saving)
php artisan ptah:config "App\Models\Product" --column="..." --dry-run
```
### Option Formats
Full option reference (every key each parser accepts): [Commands.md](../../../../docs/Commands.md#ptahconfig). What follows is what an agent needs day to day — **verify against `src/Commands/Config/Parsers/*.php` before trusting any doc, including this one, if behaviour looks off.**
#### --column
Format: `field:type:modifier:modifier:option=value:option=value` (`ColumnParser`)
**`colsTipo` values** (the FORM INPUT type — independent from the display renderer, see [KnownLimitations.md §5](../../../../docs/KnownLimitations.md#5-ptahconfig-cli--column-types-and-renderers)):
`text`, `textarea`, `number`, `date`, `datetime`, `select`, `searchdropdown`, `boolean`, `file`, `image`, `color`, `time`, `datetime-local`, `range`, `email`, `url`, `tel`, `password` (never pre-filled on edit; empty = keep; saved hashed)
A `boolean` column on a NOT NULL database column is drawn as an on/off switch; a nullable one keeps the Yes/No select.
**Bare modifiers** (no `=value` — presence alone flips the flag, never `sortable=true`):
`required` (→ `colsRequired=true`), `nullable` (→ `colsRequired=false`), `readonly` (→ `colsGravar=false`: the field is NOT shown in the form and NOT saved), `hidden` (→ `colsVisibleList=false`), `sortable` (→ `colsOrderBy=<field>`), `filterable`, `not_filterable`
To SHOW a value in the form without letting it be edited or saved, use the config key instead: `colsReadonly=true` (accepted as-is by `--column`; unlike `colsEditableForm=false`, which removes the field).
**Common `key=value` options:**
- `label=` — display label
- `renderer=` — how the value is DISPLAYED in the list table (`badge`, `pill`, `boolean`, `money`, `date`, `datetime`, `link`, `image`, `truncate`, `number`, `filesize`, `duration`, `code`, `color`, `progress`, `rating`, `qrcode`) — a separate concept from `colsTipo`
- `min_width=` — e.g. `min_width=120px` (there is no `width=`)
- `badges=value|color|label,value2|color2|label2` — **pipe**-separated inside each entry (`:` is the field:type:modifier separator and would collide)
- `options=value:Label,value2:Label2` — for `select`/`searchdropdown`
- `validation=rule|rule` — extra form rules (`colsValidations`), e.g. `validation=email|min:0|maxLength:255`. `required` is NOT a rule here — it is the `:required` modifier (`colsRequired`); `max:`/`min:` compare a NUMBER, string length is `maxLength:`/`minLength:`
- `mask=`, `mask_transform=` — input mask
- `sd_model=`, `sd_value=`, `sd_label=` — searchdropdown source (a `searchdropdown` without `sd_model` or `sd_service` is refused)
- `sd_mode=service`, `sd_service=`, `sd_service_method=` — searchdropdown fed by a service: the class MUST implement `Ptah\Contracts\SearchDropdownSource` (`searchDropdown(string $query, string $key): array` of `['value' => …, 'label' => …]`); `sd_service_method` is only a KEY handed to it, never a method ptah calls. Without the interface the dropdown returns nothing
- `link_template=`, `link_label=`, `link_new_tab=` — for `renderer=link`
- `upload_path=`, `upload_max_size=`, `upload_allowed_types=` — for `colsTipo=file|image`
- `permission=` — gates the column via `colsPermission` (see [Permissions.md](../../../../docs/Permissions.md))
**Example:** `is_active:select:label=Status:renderer=badge:badges=1|success|Ativo,0|danger|Inativo`
#### --style
Format: `field:condition:value:style` — `style` is an **inline CSS declaration
list** (it is rendered as `style="{{ ... }}"` on the row), not Tailwind
classes.
**Conditions:** `==`, `!=`, `>`, `<`, `>=`, `<=`, `*` (no test — applies to every row) (or the aliases `eq`, `ne`,
`lt`, `gt`, `lte`, `gte`, `=`, `always`, `any`, `true`)
**Example:** `is_active:==:0:background:#FEF2F2;color:#B91C1C;`
#### --filter
Format: `field:type:option=value:option=value` (`FilterParser`) — **no positional
operator**; everything after `field:type` is `key=value`.
**Types:** `text`, `number`, `date`, `select` — there is no `searchdropdown` custom filter (a filterable `searchdropdown` COLUMN already gets one in the panel)
**Common options:** `label=`, `operator=` (`=`, `!=`, `>`, `<`, `>=`, `<=`, `LIKE`), `options=`, `whereHas=`, `field_relation=`, `aggregate=`
**Example:** `status:select:label=Status:operator==:options=active:Active,inactive:Inactive`
#### --action
Format: `name:type:value:option=value:option=value` (`ActionParser`)
**Types (the only ones the table renderer executes — `_table.blade.php`):** `link`, `livewire`, `javascript`
**Common options:** `icon=`, `color=` (`primary`, `success`, `danger`, `warning`, `info`, `secondary`), `confirm=true`, `confirm_message=`, `permission=`
`icon=` is a Boxicons name; since 1.50.0 it works with or without the family prefix (`bx-check` and `bx bx-check`).
**Example:** `approve:livewire:approve(%id%):icon=bx-check:color=success:confirm=true`
**`references/config-cli.md`** — `--join`, `--permission` and custom bulk
actions (`BulkActionHandler`). Read it when the task touches one of them.
#### --set
Format: `key=value`
**Settings:** `itemsPerPage=15` (→ `uiPreferences.perPage`), `exportEnabled=true` (→ `exportConfig.enabled`), `displayName=Produtos`, or any dotted path (`--set="exportConfig.maxRows=5000"`). Cache and pagination switches do not exist — the command warns and ignores them (`cacheEnabled`, `paginationEnabled`, `searchEnabled`, `theme`, …).
### UI helpers agents reach for often
- **Toast:** `$this->dispatch('ptah-toast', title: 'Salvo com sucesso.', color: 'success');` — `color` is `success`/`danger`/`warn`. An undo toast (`ptah-toast-undo`) is dispatched by the delete flow and wired to `restoreRecord()` — see [BaseCrud.md](../../../../docs/BaseCrud.md).
- **Empty/loading states:** use `<x-forge-empty>` / `<x-forge-skeleton>` instead of ad-hoc markup.
- **Appearance tokens/eixos** (density, tone, font, radius, motion, contrast): see [CustomScreens.md](../../../../docs/CustomScreens.md) and [Configuration.md](../../../../docs/Configuration.md).
---
## Configuring BaseCrud (JSON)
The persisted config shape (`crud_configs.config`) uses the SAME property names the
runtime reads directly — `colsNomeFisico`, `colsTipo`, etc. — not a `field`/`label`/`type`
shorthand. Full property reference: [Configuration.md § Column Configuration](../../../../docs/Configuration.md#column-configuration).
```json
{
"cols": [
{ "colsNomeFisico": "name", "colsNomeLogico": "Nome", "colsTipo": "text",
"colsGravar": true, "colsRequired": true, "colsIsFilterable": true,
"colsVisibleList": true, "colsEditableForm": true },
{ "colsNomeFisico": "price", "colsNomeLogico": "Price", "colsTipo": "number",
"colsRenderer": "money", "colsRendererCurrency": "BRL", "colsRendererDecimals": 2 },
{ "colsNomeFisico": "is_active", "colsNomeLogico": "Status", "colsTipo": "select",
"colsRenderer": "badge",
"colsRendererBadges": [
{ "value": "1", "color": "success", "label": "Ativo" },
{ "value": "0", "color": "danger", "label": "Inativo" }
] },
{ "colsNomeFisico": "id", "colsNomeLogico": "Aprovar", "colsTipo": "action",
"colsGravar": false, "colsRequired": false, "colsIsFilterable": false,
"actionType": "livewire", "actionValue": "approve(%id%)",
"actionIcon": "bx-check", "actionColor": "success", "actionConfirm": true }
],
"customFilters": [
{ "field": "is_active", "label": "Status", "colsFilterType": "select",
"operator": "=", "colsSelect": { "Ativo": "1", "Inativo": "0" } }
],
"contitionStyles": [
{ "field": "stock", "condition": "<", "value": "5",
"style": "background:#FEFCE8;color:#A16207;" }
],
"joins": [
{ "type": "left", "table": "categories",
"first": "products.category_id", "second": "categories.id" }
],
"permissions": { "permissionIdentifier": "products.index" },
"uiPreferences": { "perPage": 25 }
}
```
> Note the misspelling `contitionStyles` (not `conditionStyles`) — that is the key
> `HasCrudRenderers::getRowStyle()` actually reads; `StyleRule::normalize()` is the
> single place that canonicalises every legacy shape into this one. Never invent a
> `rowStyles`/`badgeMap`/`modal.fields` shape — the runtime does not read it.
> Row actions are entries of `cols` with `colsTipo: "action"` (what `--action`
> writes), and custom filters live in `customFilters` — a top-level `actions` or
> `filters` list is never read.
---
## Livewire Input Rules
```blade
{{-- Text, email, phone, tax → .blur (no re-renders while typing) --}}
<x-forge-input wire:model.blur="name" name="name" label="Nome" />
<x-forge-input wire:model.blur="email" name="email" label="E-mail" />
{{-- Switch / checkbox / select → .live (immediate UI feedback needed) --}}
<x-forge-switch wire:model.live="is_active" name="is_active" label="Ativo" />
```
Unique validation with self-exclusion:
```php
use Illuminate\Validation\Rule;
protected function rules(): array
{
return [
'sku' => [
'required', 'string', 'max:50',
Rule::unique('products', 'sku')->ignore($this->editingId),
],
];
}
```
---
## Optional Modules
```bash
php artisan ptah:module auth # Login, 2FA TOTP+email, sessions, profile
php artisan ptah:module menu # Dynamic sidebar (driver: config or database)
php artisan ptah:module company # Multi-company + departments
php artisan ptah:module permissions # RBAC: roles, page objects, CRUD + audit
php artisan ptah:module --list # Status of all modules
php artisan ptah:install --demo # Seed demo companies/roles/menu
```
---
## Writing Tests
```php
use Livewire\Livewire;
// `Ptah\Tests\TestCase` (Testbench + RefreshDatabase + SQLite :memory:) is the
// PACKAGE's own dev-only base class, registered under ptah's `autoload-dev` —
// it is NOT available to a consuming app. In a host app, extend the host's own
// `Tests\TestCase` (Laravel's default, `tests/TestCase.php`) instead.
use Tests\TestCase;
use Tests\Factories\ProductFactory; // Custom factory — NO Eloquent Factory
class ProductListTest extends TestCase
{
public function test_can_create(): void
{
Livewire::test(ProductList::class)
->call('create')
->set('name', 'Widget')->set('sku', 'WGT-001')
->call('save')
->assertHasNoErrors()
->assertSet('showModal', false);
$this->assertDatabaseHas('products', ['sku' => 'WGT-001']);
}
public function test_sku_unique(): void
{
ProductFactory::new()->create(['sku' => 'DUP']);
Livewire::test(ProductList::class)
->call('create')->set('sku', 'DUP')->call('save')
->assertHasErrors(['sku']);
}
public function test_can_edit_own_sku(): void
{
$p = ProductFactory::new()->create(['sku' => 'MINE']);
Livewire::test(ProductList::class)
->call('edit', $p->id)->set('name', 'New Name')->call('save')
->assertHasNoErrors();
}
public function test_soft_delete(): void
{
$p = ProductFactory::new()->create();
Livewire::test(ProductList::class)
->call('confirmDelete', $p->id)
->call('delete');
$this->assertSoftDeleted('products', ['id' => $p->id]);
}
}
```
Factory pattern:
```php
class ProductFactory
{
public static function new(): static { return new static(); }
public function create(array $attrs = []): Product
{
$m = new Product(array_merge([
'name' => 'Product ' . \Str::random(4),
'sku' => strtoupper(\Str::random(6)),
'price' => 49.90, 'is_active' => true,
], $attrs));
$m->save();
return $m->fresh();
}
}
```
---
## API Module (`ptah:module api`)
### Activation
```bash
php artisan ptah:module api
```
Automatically installs `darkaonline/l5-swagger` and publishes:
- `app/Responses/BaseResponse.php` — the standard response envelope
- `app/Http/Controllers/API/BaseApiController.php` — base controller
- `app/Http/Controllers/API/SwaggerInfo.php` — Swagger metadata (`@OA\Info`, `@OA\SecurityScheme`)
### Generating entities with an API
```bash
# Combined mode (web + API in a single command) — recommended
php artisan ptah:forge Catalog/Product \
--fields="name:string,price:decimal(10,2),category_id:unsignedBigInteger,is_active:boolean" \
--api
```
Automatically generates **web and API together**:
- `app/Http/Controllers/Catalog/ProductController.php` — web controller (renders the index view)
- `resources/views/product/index.blade.php` — view that mounts the `ptah-base-crud` Livewire screen
- `app/Http/Controllers/API/Catalog/ProductApiController.php` — Swagger `@OA\*` completo
- `app/Http/Requests/API/Catalog/CreateProductApiRequest.php`
- `app/Http/Requests/API/Catalog/UpdateProductApiRequest.php`
- `app/Models/Catalog/Product.php` — `@OA\Schema` gerado
- `routes/web.php` (web route, with `auth` when the module is active) + `routes/api.php` (`Route::prefix('v1')->middleware(config('ptah.api.middleware'))` group, only with `--api`; requires an existing `routes/api.php`)
> **Model preserved:** if the entity already exists, `--api` only injects the `@OA\Schema`
> block into the model, never overwriting `$fillable`, `$casts` or relationships.
> **API only (no views):** use `--api-only` — the legacy behaviour of the old `--api`.
### Workflow completo
```bash
# 1. Install the module (once per project)
php artisan ptah:module api
# 2. Generate the entity (web + API together)
php artisan ptah:forge Catalog/Product \
--fields="name:string,price:decimal(10,2)" \
--api
# 3. Resolve any ⚠ forge printed (FK imports — see the checklist above)
# 4. Run pint
./vendor/bin/pint
# 5. Migrate
php artisan migrate
# 6. Generate the Swagger docs
php artisan l5-swagger:generate
# 7. Open the docs
# http://localhost/api/documentation
```
### BaseResponse — usage rules
**ALWAYS** use `BaseResponse::` — **NEVER** call `response()->json()` directly.
```php
use App\Responses\BaseResponse;
// index — paginated
return BaseResponse::paginated($this->service->getData($request));
// show — single record
$item = $this->service->show($id);
return $item ? BaseResponse::ok($item) : BaseResponse::notFound('Product not found');
// store
return BaseResponse::created($this->service->create($request->validated()));
// update
return BaseResponse::ok($this->service->update($id, $request->validated()));
// destroy
return $this->service->destroy($id) ? BaseResponse::noContent() : BaseResponse::notFound();
// custom error
return BaseResponse::error('Message', 422, ['field' => 'detail']);
```
**Response envelope:**
```json
{
"success": true,
"message": "OK",
"data": { ... },
"meta": { "current_page": 1, "total": 50, ... }
}
```
### getData($request) — smart listing
`BaseService::getData(Request $request)` orchestrates the whole listing from request parameters:
| Parameter | Behaviour |
|---|---|
| `search` | OR `LIKE %term%` across every table column (comma = several terms) |
| `searchLike` | Incremental filter: `field}5` (>=), `field{5` (<=), `field>5`, `field<5`; plus the `whereIn` and `additionalQueries` params |
| neither of them | Exact AND (`findAllFieldsAnd`) |
| `limit`, `page` | Automatic pagination |
| `order`, `direction` | Sorting |
| `fields` | Select only specific columns |
| `relations` | Eager load (comma-separated) |
```php
// In the controller, this is all of it:
public function index(Request $request): JsonResponse
{
return BaseResponse::paginated($this->service->getData($request));
}
```
### Namespaces e naming conventions
| Artefato | Caminho | Classe |
|---|---|---|
| Controller | `Http/Controllers/API/{Folder}/` | `{Entity}ApiController` |
| Request criar | `Http/Requests/API/{Folder}/` | `Create{Entity}ApiRequest` |
| Request atualizar | `Http/Requests/API/{Folder}/` | `Update{Entity}ApiRequest` |
| Rotas | `routes/api.php` | prefixo `v1` + middleware da config |
### Anti-patterns proibidos
```php
// ❌ NUNCA — query no controller
public function index() {
return Product::where('active', true)->get();
}
// ❌ NUNCA — response()->json() avulso
return response()->json(['data' => $data]);
// ❌ NEVER — business logic in the controller
public function store(Request $request) {
if (Product::where('sku', $request->sku)->exists()) { ... }
}
// ✅ CERTO
public function index(Request $request): JsonResponse
{
return BaseResponse::paginated($this->service->getData($request));
}
```
---
## Performance & high-demand architecture
Throughput work — caching, queues, indexes, N+1, chunking, Livewire payload —
lives in a reference file so it does not cost you tokens on every task:
**`references/performance.md`** (read it when the task is actually about
performance; it opens with a measurement of what the package's own layers
cost, so you do not optimise on suspicion).
The three rules worth carrying without reading it:
1. **Never** loop a query — eager-load or `chunkById`.
2. Cache with a key that carries what invalidates it; the package invalidates
by generation, never by `cache:clear`.
3. A job that touches rows written in the same request needs
`$afterCommit = true`, honoured even by the `sync` driver.
## Commit Convention
> ⚠️ **ALWAYS run Pint before any commit.** Never commit unformatted PHP code.
```bash
# REQUIRED before every git commit:
./vendor/bin/pint
# Then commit:
git add .
git commit -m "feat: ..."
```
```
feat: new feature
fix: bug fix
docs: documentation only
refactor: no feat/fix
test: tests
chore: maintenance (deps, config)
```
Use this skill when:
The most expensive mistake an agent makes here is rebuilding something the package already ships. Read this table before writing any file; each row names the ready-made path and what NOT to do.
| You need | Do this | Do NOT |
|---|---|---|
| A new entity + full CRUD screen | php artisan ptah:forge Name --fields="..." then configure via ptah:config or the visual editor (gear icon on the screen) |
Hand-write a Livewire listing/form component |
| Columns, filters, badges, row styles, actions, modal fields on an existing CRUD | ptah:config flags or the visual editor — it is all JSON in crud_configs |
Edit Blade views or create custom components |
| A listing of an existing table | A crud_configs row is enough — BaseCrud renders from config; no view, no component, no controller beyond the thin generated one |
Build a table view by hand |
| Data access / business rules outside a CRUD screen | Extend BaseRepository / BaseService via contracts (see the ptah-data-layer skill — getData(Request) already does search/filter/sort/paginate, and the bases ship the CRUD method set: all/paginate/find/create/update/delete/destroy — there is no restore; soft-delete restore is done by BaseCrud) |
Write Eloquent queries in Livewire/controllers |
| A REST API for an entity | php artisan ptah:module api once, then ptah:forge Name --fields="..." --api — controller with full Swagger @OA\* annotations, Create/Update API requests, versioned Route::prefix('v1') routes and the BaseResponse envelope, all generated and working |
Hand-write API controllers, resources or response envelopes |
| Notify users when records change | CrudConfig editor → Notifications tab + SendsCrudNotifications trait on the model |
Write observers/listeners that insert notifications |
| Permissions per screen / column | permissionIdentifier + column tag colsPermission, checked by the ptah Permissions module — or, with ptah.permissions.driver = gate, by Laravel's Gate (Spatie, policies) |
if() checks scattered in views |
| A whole module (several related entities) | php artisan ptah:blueprint spec.json --dry-run, then without --dry-run — forge in FK order, migrate, config, menu, permissions, seed (AgentTools.md) |
Run a dozen commands by hand and get the parent/child order wrong |
| Import, change history, attachments, board or calendar on a screen | Gear icon → General → Features (or importConfig/kanbanConfig/calendarConfig); history and attachments also need RecordsHistory/HasAttachments on the model and ptah:history:install/ptah:attachments:install |
Build upload screens, audit tables or calendars by hand |
| Dashboard numbers, or settings an admin changes | config/ptah-dashboard.php widgets; config/ptah-settings.php + ptah_setting() (ptah:settings:install) — docs/Dashboard.md, docs/Settings.md |
A hand-made dashboard controller or a settings table |
| A new column on an existing entity | php artisan ptah:field Entity add name:type[:modifiers] — migration, $fillable/$casts, rules, DTO and crud config in one call |
Edit five files and forget $fillable (the field is then silently not saved) |
| A screen that is genuinely not a CRUD | CustomScreens.md: <x-forge-*> components + --ptah-* tokens only |
Raw HTML with Tailwind palette colors |
Global search + per-column search · custom filters with operators · date-range and quick-date filters · saved filters · sorting (table headers + card-view sort bar) · pagination with per-page control · export XLSX/CSV/PDF + print · bulk actions (delete/restore/export + custom, permission-gated) · row styles by condition · badges with value→color maps · relation columns via JOIN · SearchDropdown fields (model or service mode, masks, filters) · master-detail · group break with subtotals · totalizers · soft deletes + trash view + restore · audit fields (created/updated/deleted_by) · column-level permissions · ACL integration · per-CRUD event notifications · responsive card layout (auto on mobile) with its own sorting · per-user preferences (columns, widths, order, view mode, density) · keyboard shortcuts · CSV/JSON config import/export · lifecycle hooks for custom logic around save/delete.
If the request maps to anything above, the answer is configuration, not
code. When in doubt: php artisan ptah:config "App\Models\X" --list shows
what a screen already has.
Each of these answers in one call what would otherwise take several file reads. Prefer them — they read the same truth the runtime reads.
| To know | Run |
|---|---|
| What exists (entities, fields, relations, screens, menu, TODOs) | php artisan ptah:map |
| What one screen has, before editing it | php artisan ptah:screen Entity — not the JSON |
| Why a screen is empty for a user | php artisan ptah:why-empty Entity --as=<id> |
The exact syntax of a --column / --filter / --style / --action / --join / mask |
php artisan ptah:docs column (or filter, style, …) |
| Whether every screen still works after a change | php artisan ptah:check (--write also round-trips a save, rolled back) |
| Why something broke | php artisan ptah:last-error — never tail the log |
| What an update of ptah needs in this project | php artisan ptah:upgrade-check |
| Whether a config JSON you wrote is right, BEFORE saving it | php artisan ptah:config:doctor --file=x.json --model=Entity |
| How a feature works (any subject) | php artisan ptah:docs --search="column permission" |
The same are MCP tools (Boost's server, or php artisan mcp:start ptah):
ptah-map, ptah-screen, ptah-check, ptah-docs (search),
ptah-why-empty, ptah-last-error, ptah-upgrade-check, ptah-doctor,
ptah-validate-config.
ptah:forge … --factory also writes a factory and a demo seeder from the
field types.
Read the SMALLEST document that answers the question — in this order:
| Question | Read |
|---|---|
| Any config flag / column type / option syntax | php artisan ptah:docs <topic>, then this skill's "Configuring BaseCrud" sections below |
| Full BaseCrud runtime behaviour | docs/BaseCrud.md |
Every ptah:* command |
docs/Commands.md |
| Repository/Service/DTO contracts | ptah-data-layer skill, then docs/BaseLayer.md |
| Custom screens, tokens, theming | docs/CustomScreens.md |
| Permissions/ACL | docs/Permissions.md |
| Notifications | docs/Notifications.md |
| What is known-broken or deliberate | docs/KnownLimitations.md |
HTTP Request
│
▼
FormRequest → validates HTTP input only
│
▼
Controller / Livewire → calls Service via Contract; NO queries, NO business logic
│
▼
ServiceContract → interface in app/Contracts/Services/ (convention — forge does NOT generate one)
│
▼
Service → ALL business logic; calls RepositoryContract; throws domain exceptions
│
▼
RepositoryContract → interface in app/Contracts/Repositories/ (forge generates it as
app/Repositories/Contracts/{Entity}RepositoryInterface + the binding)
│
▼
Repository → ALL database access; Eloquent queries, filters, pagination
│
▼
Model → schema, casts, scopes, relationships, boot hooks; NO logic
│
▼
DTO → immutable value object; fromArray(); passed between layers
| Layer | Owns | Must NOT contain |
|---|---|---|
| Model | $fillable, $casts, scopes, relationships, boot() hooks |
Business rules, DB queries in methods |
| DTO | readonly properties, fromArray(), toArray() |
Persistence, validation |
| Repository | Eloquent queries, raw SQL, pagination, eager loads | Business rules, HTTP/session awareness |
| Service | Business rules, orchestration, events, domain exceptions | Eloquent queries, HTTP redirects |
| FormRequest | rules(), authorize(), messages() |
Business logic |
| Livewire/Controller | Call Service, return view/response | Queries, business rules |
// ✅ Correct
public function __construct(
private readonly ProductServiceContract $products,
private readonly CategoryRepositoryContract $categories,
) {}
// ❌ Wrong — never inject concrete classes
public function __construct(private readonly ProductService $products) {}
// ❌ Anti-pattern: business logic leaking into Livewire
public function save(): void
{
$exists = Product::where('sku', $this->sku)->exists(); // query in Livewire ❌
if ($exists) { $this->addError('sku', 'Duplicado'); return; }
Product::create([...]); // direct create in Livewire ❌
}
// ✅ Correct: Livewire → Service (via Contract) → Repository
// Livewire:
public function save(): void
{
$this->validate();
try {
$this->products->create(ProductDTO::fromArray($this->only(['name','sku','price'])));
$this->showModal = false;
} catch (DuplicateSkuException $e) {
$this->addError('sku', $e->getMessage());
}
}
// Service:
public function create(ProductDTO $dto): Product
{
if ($this->repo->existsBySku($dto->sku)) {
throw new DuplicateSkuException("SKU {$dto->sku} already registered.");
}
return $this->repo->create($dto->toArray());
}
// Repository:
public function existsBySku(string $sku): bool
{
return Product::where('sku', $sku)->exists();
}
Ptah has 6 per-user appearance axes (light tone, dark tone, accent, text weight, density, font size). The user can switch the light tone to papel (warm paper) or nevoa at any time, and every ptah screen follows. A screen follows the theme only if every color in it resolves through a CSS custom property the presets rewrite. A fixed Tailwind palette class does not.
The failure this rule prevents, seen in production: a screen built with
bg-white / bg-light stays literally white when the user switches the tone
to papel — it is the one white rectangle on a warm-paper page.
<x-forge-*> component props — zero theming work, always safe:<x-forge-button color="primary">Salvar</x-forge-button>
<x-forge-button color="danger" flat>Excluir</x-forge-button>
<x-forge-alert type="success">Salvo!</x-forge-alert>
Semantic Tailwind classes that are theme-safe — safe because their
--color-* variable is rewritten by a preset (accent axis) or is a
deliberate constant (status colors mean the same thing in every theme):
bg-primary text-primary · bg-success text-success ·
bg-danger text-danger · bg-warn text-warn
var(--ptah-*) tokens for any custom surface/text/border — the full
contract (~30 tokens with usage notes) is
CustomScreens.md §1; the ones you will
need constantly:
| Need | Token |
|---|---|
| Page-flush background (toolbar, filter panel) | --ptah-canvas |
| Card / button / modal surface | --ptah-surface |
| Elevated surface (dropdown menu) | --ptah-surface-raised |
| Recessed panel (modal body) | --ptah-surface-sunken |
| Hover tint | --ptah-surface-hover |
| Default / secondary / faint text | --ptah-text / --ptah-text-secondary / --ptah-text-faint |
| Border / stronger border | --ptah-line / --ptah-line-strong |
| Control height & font (density axis) | --ptah-control-h / --ptah-control-fs |
bg-white, bg-light, bg-dark, bg-slate-*, bg-gray-*, text-slate-*
and every other fixed-palette class used for surfaces or text — they
ignore the tone presets.dark: Tailwind variants — dark mode here is .ptah-dark redefining the
SAME tokens; markup never branches on the mode.<style> blocks (which are
themselves forbidden — see CSS Architecture Rules).{{-- ❌ stays white under the papel tone --}}
<div class="bg-white dark:bg-slate-800 border rounded-lg p-4">
{{-- ✅ follows every axis with zero extra work --}}
<div class="rounded-lg p-4 border"
style="background: var(--ptah-surface); border-color: var(--ptah-line);">
To find offenders in an existing project:
grep -rnE 'bg-(white|light|slate|gray)|dark:|#[0-9a-fA-F]{3,6}' resources/views --include='*.blade.php'
# Single entity (SoftDeletes is ON by default — pass --no-soft-deletes to disable)
php artisan ptah:forge Product \
--fields="name:string,sku:string,price:decimal(10,2),stock:integer,category_id:unsignedBigInteger,is_active:boolean"
# Sub-folder (large projects)
php artisan ptah:forge Inventory/ProductStock \
--fields="product_id:unsignedBigInteger,location:string,qty:integer"
# model key = 'Inventory/ProductStock'
# namespace = App\Models\Inventory\ProductStock
--fields types: integer, bigInteger, mediumInteger, smallInteger,
tinyInteger, their unsigned* forms, foreignId (constrained FK),
string, char, text, longText, decimal, float, double, boolean,
date, datetime, timestamp, json, enum (aliases: int, bigint,
uint, ubigint, bool, foreign, jsonb). Any other type is refused
(FieldDefinition::TYPES). Always state decimal(p,s) — a bare decimal
silently becomes decimal(10,2). Modifiers: :nullable, :unique, :index,
:default(val), :label=Text.
# With API (web + API in one command)
php artisan ptah:forge Catalog/Product \
--fields="name:string,price:decimal(10,2),category_id:unsignedBigInteger" \
--api
Every entity generated automatically adds a sidebar menu link to
database/seeders/MenuRegistry.php (needs ptah:install to have created it) —
under a group when the entity has a subfolder, as a flat root link when not:
# During scaffolding
php artisan ptah:forge Health/VaccinationType --fields="..."
# → Adds an entry to database/seeders/MenuRegistry.php
# After generating all entities, sync the menu:
php artisan ptah:menu-sync
# → Populates the 'menus' table with every link
Automatic mappings:
Health → group "Saúde" (icon bx bx-plus-medical)VaccinationType → link "Tipos de Vacina" (icon bx bx-shield-plus)Disabling the menu for one entity:
php artisan ptah:forge Health/Test --fields="..." --no-menu
After running ptah:forge and php artisan migrate, always perform these steps:
ptah:forge reported a ⚠The generator resolves each foreign key's use itself when exactly one class
of that name exists in app/Models, and its output says what it did:
Relationship imports:
✔ category_id → App\Models\Catalog\Category
⚠ supplier_id → Supplier not found in app/Models yet — generate it, then fix the TODO in the model
Only a ⚠ line needs you: the related model was not generated yet, or more
than one class has that name (the TODO lists them). Generate entities in
dependency order — Category before Product — and there is nothing to fix.
Never leave a // TODO: line in committed code.
./vendor/bin/pint
php artisan migrate
php artisan view:clear
php artisan config:clear
Every model generated by ptah:forge automatically includes the Ptah\Traits\HasAuditFields trait and audit columns in its migration. All internal package models (Company, Department, Role, Menu, CrudConfig, PtahPage, PageObject, UserRole, RolePermission) also use this trait.
| Column | Type | Filled when |
|---|---|---|
created_by |
unsignedBigInteger nullable |
Eloquent creating event |
updated_by |
unsignedBigInteger nullable |
Eloquent creating and updating events |
deleted_by |
unsignedBigInteger nullable |
Eloquent deleted event (only when SoftDeletes is enabled — i.e. not --no-soft-deletes) |
✅ NEVER manually set created_by / updated_by in service, controller or Livewire
The HasAuditFields trait fills them automatically via Eloquent events.
BaseCrud::save() also injects them explicitly as a belt-and-suspenders safeguard.
✅ NEVER use ->whereIn()->delete() for bulk ops on models with HasAuditFields
Use ->each(fn($r) => $r->delete()) so Eloquent fires the `deleted` event per record
and deleted_by is stamped correctly on each row.
✅ deleted_by uses the `deleted` event (after soft-delete commits), NOT `deleting`
This prevents stamping deleted_by on a record whose soft-delete later fails.
✅ Guard is === null (not empty()) so user ID 0 is not treated as "unset".
$record->createdBy // BelongsTo → User (resolved via auth.providers.users.model)
$record->updatedBy // BelongsTo → User
$record->deletedBy // BelongsTo → User
use Ptah\Traits\HasAuditFields;
class Product extends Model
{
use HasAuditFields;
protected $fillable = [
// ... your fields ...
'created_by', 'updated_by', // always
'deleted_by', // only if model uses SoftDeletes
];
protected $casts = [
'created_by' => 'integer',
'updated_by' => 'integer',
'deleted_by' => 'integer', // only if SoftDeletes
];
}
ptah:forge generates all of the above automatically — no manual setup needed for scaffolded entities.
<style> blocks inside view filesresources/css/ptah-components.css, built on --ptah-* design
tokens (--ptah-primary, --ptah-surface, --ptah-text-*, …) — never a bare hex
literal. forge-dashboard-layout.blade.php still carries a legacy inline <style>
block for a shrinking set of chrome rules; it is being dismantled, not extended —
LayoutStyleBaselineTest fails the build if it gains a single color literal or rule
(see KnownLimitations.md §6).ptah-dark ancestordata-ptah-* attributes on <html>, resolved by
Ptah\Support\AppearancePresets — see CustomScreens.md/* ✅ Inside resources/css/ptah-components.css — .ptah-dark redefines the SAME
token names in its own block, components never branch on .ptah-dark themselves */
:root { --ptah-surface: #ffffff; }
.ptah-dark { --ptah-surface: #1e293b; }
.my-component { background: var(--ptah-surface); }
# Configure complete CRUD in one command
php artisan ptah:config "App\Models\Product" \
--column="id:number:label=ID:sortable:min_width=80px" \
--column="name:text:label=Nome:required:sortable" \
--column="price:number:label=Price:renderer=money:sortable" \
--column="is_active:select:label=Status:renderer=badge:badges=1|success|Ativo,0|danger|Inativo" \
--style="is_active:==:0:background:#FEF2F2;color:#B91C1C;" \
--style="stock:<:5:background:#FEFCE8;color:#A16207;" \
--filter="is_active:select:label=Ativos:operator==:options=1:Ativo,0:Inativo" \
--action="duplicate:livewire:duplicate(%id%):icon=bx-copy:color=info:confirm=true" \
--set="itemsPerPage=15"
# List current configuration
php artisan ptah:config "App\Models\Product" --list
# Export to JSON
php artisan ptah:config "App\Models\Product" --export=product-config.json
# Import from JSON
php artisan ptah:config "App\Models\Product" --import=product-config.json
# Reset to defaults
php artisan ptah:config "App\Models\Product" --reset
# Dry-run (show changes without saving)
php artisan ptah:config "App\Models\Product" --column="..." --dry-run
Full option reference (every key each parser accepts): Commands.md. What follows is what an agent needs day to day — verify against src/Commands/Config/Parsers/*.php before trusting any doc, including this one, if behaviour looks off.
Format: field:type:modifier:modifier:option=value:option=value (ColumnParser)
colsTipo values (the FORM INPUT type — independent from the display renderer, see KnownLimitations.md §5):
text, textarea, number, date, datetime, select, searchdropdown, boolean, file, image, color, time, datetime-local, range, email, url, tel, password (never pre-filled on edit; empty = keep; saved hashed)
A boolean column on a NOT NULL database column is drawn as an on/off switch; a nullable one keeps the Yes/No select.
Bare modifiers (no =value — presence alone flips the flag, never sortable=true):
required (→ colsRequired=true), nullable (→ colsRequired=false), readonly (→ colsGravar=false: the field is NOT shown in the form and NOT saved), hidden (→ colsVisibleList=false), sortable (→ colsOrderBy=<field>), filterable, not_filterable
To SHOW a value in the form without letting it be edited or saved, use the config key instead: colsReadonly=true (accepted as-is by --column; unlike colsEditableForm=false, which removes the field).
Common key=value options:
label= — display labelrenderer= — how the value is DISPLAYED in the list table (badge, pill, boolean, money, date, datetime, link, image, truncate, number, filesize, duration, code, color, progress, rating, qrcode) — a separate concept from colsTipomin_width= — e.g. min_width=120px (there is no width=)badges=value|color|label,value2|color2|label2 — pipe-separated inside each entry (: is the field:type:modifier separator and would collide)options=value:Label,value2:Label2 — for select/searchdropdownvalidation=rule|rule — extra form rules (colsValidations), e.g. validation=email|min:0|maxLength:255. required is NOT a rule here — it is the :required modifier (colsRequired); max:/min: compare a NUMBER, string length is maxLength:/minLength:mask=, mask_transform= — input masksd_model=, sd_value=, sd_label= — searchdropdown source (a searchdropdown without sd_model or sd_service is refused)sd_mode=service, sd_service=, sd_service_method= — searchdropdown fed by a service: the class MUST implement Ptah\Contracts\SearchDropdownSource (searchDropdown(string $query, string $key): array of ['value' => …, 'label' => …]); sd_service_method is only a KEY handed to it, never a method ptah calls. Without the interface the dropdown returns nothinglink_template=, link_label=, link_new_tab= — for renderer=linkupload_path=, upload_max_size=, upload_allowed_types= — for colsTipo=file|imagepermission= — gates the column via colsPermission (see Permissions.md)Example: is_active:select:label=Status:renderer=badge:badges=1|success|Ativo,0|danger|Inativo
Format: field:condition:value:style — style is an inline CSS declaration
list (it is rendered as style="{{ ... }}" on the row), not Tailwind
classes.
Conditions: ==, !=, >, <, >=, <=, * (no test — applies to every row) (or the aliases eq, ne,
lt, gt, lte, gte, =, always, any, true)
Example: is_active:==:0:background:#FEF2F2;color:#B91C1C;
Format: field:type:option=value:option=value (FilterParser) — no positional
operator; everything after field:type is key=value.
Types: text, number, date, select — there is no searchdropdown custom filter (a filterable searchdropdown COLUMN already gets one in the panel)
Common options: label=, operator= (=, !=, >, <, >=, <=, LIKE), options=, whereHas=, field_relation=, aggregate=
Example: status:select:label=Status:operator==:options=active:Active,inactive:Inactive
Format: name:type:value:option=value:option=value (ActionParser)
Types (the only ones the table renderer executes — _table.blade.php): link, livewire, javascript
Common options: icon=, color= (primary, success, danger, warning, info, secondary), confirm=true, confirm_message=, permission=
icon= is a Boxicons name; since 1.50.0 it works with or without the family prefix (bx-check and bx bx-check).
Example: approve:livewire:approve(%id%):icon=bx-check:color=success:confirm=true
references/config-cli.md — --join, --permission and custom bulk
actions (BulkActionHandler). Read it when the task touches one of them.
Format: key=value
Settings: itemsPerPage=15 (→ uiPreferences.perPage), exportEnabled=true (→ exportConfig.enabled), displayName=Produtos, or any dotted path (--set="exportConfig.maxRows=5000"). Cache and pagination switches do not exist — the command warns and ignores them (cacheEnabled, paginationEnabled, searchEnabled, theme, …).
$this->dispatch('ptah-toast', title: 'Salvo com sucesso.', color: 'success'); — color is success/danger/warn. An undo toast (ptah-toast-undo) is dispatched by the delete flow and wired to restoreRecord() — see BaseCrud.md.<x-forge-empty> / <x-forge-skeleton> instead of ad-hoc markup.The persisted config shape (crud_configs.config) uses the SAME property names the
runtime reads directly — colsNomeFisico, colsTipo, etc. — not a field/label/type
shorthand. Full property reference: Configuration.md § Column Configuration.
{
"cols": [
{ "colsNomeFisico": "name", "colsNomeLogico": "Nome", "colsTipo": "text",
"colsGravar": true, "colsRequired": true, "colsIsFilterable": true,
"colsVisibleList": true, "colsEditableForm": true },
{ "colsNomeFisico": "price", "colsNomeLogico": "Price", "colsTipo": "number",
"colsRenderer": "money", "colsRendererCurrency": "BRL", "colsRendererDecimals": 2 },
{ "colsNomeFisico": "is_active", "colsNomeLogico": "Status", "colsTipo": "select",
"colsRenderer": "badge",
"colsRendererBadges": [
{ "value": "1", "color": "success", "label": "Ativo" },
{ "value": "0", "color": "danger", "label": "Inativo" }
] },
{ "colsNomeFisico": "id", "colsNomeLogico": "Aprovar", "colsTipo": "action",
"colsGravar": false, "colsRequired": false, "colsIsFilterable": false,
"actionType": "livewire", "actionValue": "approve(%id%)",
"actionIcon": "bx-check", "actionColor": "success", "actionConfirm": true }
],
"customFilters": [
{ "field": "is_active", "label": "Status", "colsFilterType": "select",
"operator": "=", "colsSelect": { "Ativo": "1", "Inativo": "0" } }
],
"contitionStyles": [
{ "field": "stock", "condition": "<", "value": "5",
"style": "background:#FEFCE8;color:#A16207;" }
],
"joins": [
{ "type": "left", "table": "categories",
"first": "products.category_id", "second": "categories.id" }
],
"permissions": { "permissionIdentifier": "products.index" },
"uiPreferences": { "perPage": 25 }
}
Note the misspelling
contitionStyles(notconditionStyles) — that is the keyHasCrudRenderers::getRowStyle()actually reads;StyleRule::normalize()is the single place that canonicalises every legacy shape into this one. Never invent arowStyles/badgeMap/modal.fieldsshape — the runtime does not read it. Row actions are entries ofcolswithcolsTipo: "action"(what--actionwrites), and custom filters live incustomFilters— a top-levelactionsorfilterslist is never read.
{{-- Text, email, phone, tax → .blur (no re-renders while typing) --}}
<x-forge-input wire:model.blur="name" name="name" label="Nome" />
<x-forge-input wire:model.blur="email" name="email" label="E-mail" />
{{-- Switch / checkbox / select → .live (immediate UI feedback needed) --}}
<x-forge-switch wire:model.live="is_active" name="is_active" label="Ativo" />
Unique validation with self-exclusion:
use Illuminate\Validation\Rule;
protected function rules(): array
{
return [
'sku' => [
'required', 'string', 'max:50',
Rule::unique('products', 'sku')->ignore($this->editingId),
],
];
}
php artisan ptah:module auth # Login, 2FA TOTP+email, sessions, profile
php artisan ptah:module menu # Dynamic sidebar (driver: config or database)
php artisan ptah:module company # Multi-company + departments
php artisan ptah:module permissions # RBAC: roles, page objects, CRUD + audit
php artisan ptah:module --list # Status of all modules
php artisan ptah:install --demo # Seed demo companies/roles/menu
use Livewire\Livewire;
// `Ptah\Tests\TestCase` (Testbench + RefreshDatabase + SQLite :memory:) is the
// PACKAGE's own dev-only base class, registered under ptah's `autoload-dev` —
// it is NOT available to a consuming app. In a host app, extend the host's own
// `Tests\TestCase` (Laravel's default, `tests/TestCase.php`) instead.
use Tests\TestCase;
use Tests\Factories\ProductFactory; // Custom factory — NO Eloquent Factory
class ProductListTest extends TestCase
{
public function test_can_create(): void
{
Livewire::test(ProductList::class)
->call('create')
->set('name', 'Widget')->set('sku', 'WGT-001')
->call('save')
->assertHasNoErrors()
->assertSet('showModal', false);
$this->assertDatabaseHas('products', ['sku' => 'WGT-001']);
}
public function test_sku_unique(): void
{
ProductFactory::new()->create(['sku' => 'DUP']);
Livewire::test(ProductList::class)
->call('create')->set('sku', 'DUP')->call('save')
->assertHasErrors(['sku']);
}
public function test_can_edit_own_sku(): void
{
$p = ProductFactory::new()->create(['sku' => 'MINE']);
Livewire::test(ProductList::class)
->call('edit', $p->id)->set('name', 'New Name')->call('save')
->assertHasNoErrors();
}
public function test_soft_delete(): void
{
$p = ProductFactory::new()->create();
Livewire::test(ProductList::class)
->call('confirmDelete', $p->id)
->call('delete');
$this->assertSoftDeleted('products', ['id' => $p->id]);
}
}
Factory pattern:
class ProductFactory
{
public static function new(): static { return new static(); }
public function create(array $attrs = []): Product
{
$m = new Product(array_merge([
'name' => 'Product ' . \Str::random(4),
'sku' => strtoupper(\Str::random(6)),
'price' => 49.90, 'is_active' => true,
], $attrs));
$m->save();
return $m->fresh();
}
}
ptah:module api)php artisan ptah:module api
Automatically installs darkaonline/l5-swagger and publishes:
app/Responses/BaseResponse.php — the standard response envelopeapp/Http/Controllers/API/BaseApiController.php — base controllerapp/Http/Controllers/API/SwaggerInfo.php — Swagger metadata (@OA\Info, @OA\SecurityScheme)# Combined mode (web + API in a single command) — recommended
php artisan ptah:forge Catalog/Product \
--fields="name:string,price:decimal(10,2),category_id:unsignedBigInteger,is_active:boolean" \
--api
Automatically generates web and API together:
app/Http/Controllers/Catalog/ProductController.php — web controller (renders the index view)resources/views/product/index.blade.php — view that mounts the ptah-base-crud Livewire screenapp/Http/Controllers/API/Catalog/ProductApiController.php — Swagger @OA\* completoapp/Http/Requests/API/Catalog/CreateProductApiRequest.phpapp/Http/Requests/API/Catalog/UpdateProductApiRequest.phpapp/Models/Catalog/Product.php — @OA\Schema geradoroutes/web.php (web route, with auth when the module is active) + routes/api.php (Route::prefix('v1')->middleware(config('ptah.api.middleware')) group, only with --api; requires an existing routes/api.php)Model preserved: if the entity already exists,
--apionly injects the@OA\Schemablock into the model, never overwriting$fillable,$castsor relationships.
API only (no views): use
--api-only— the legacy behaviour of the old--api.
# 1. Install the module (once per project)
php artisan ptah:module api
# 2. Generate the entity (web + API together)
php artisan ptah:forge Catalog/Product \
--fields="name:string,price:decimal(10,2)" \
--api
# 3. Resolve any ⚠ forge printed (FK imports — see the checklist above)
# 4. Run pint
./vendor/bin/pint
# 5. Migrate
php artisan migrate
# 6. Generate the Swagger docs
php artisan l5-swagger:generate
# 7. Open the docs
# http://localhost/api/documentation
ALWAYS use BaseResponse:: — NEVER call response()->json() directly.
use App\Responses\BaseResponse;
// index — paginated
return BaseResponse::paginated($this->service->getData($request));
// show — single record
$item = $this->service->show($id);
return $item ? BaseResponse::ok($item) : BaseResponse::notFound('Product not found');
// store
return BaseResponse::created($this->service->create($request->validated()));
// update
return BaseResponse::ok($this->service->update($id, $request->validated()));
// destroy
return $this->service->destroy($id) ? BaseResponse::noContent() : BaseResponse::notFound();
// custom error
return BaseResponse::error('Message', 422, ['field' => 'detail']);
Response envelope:
{
"success": true,
"message": "OK",
"data": { ... },
"meta": { "current_page": 1, "total": 50, ... }
}
BaseService::getData(Request $request) orchestrates the whole listing from request parameters:
| Parameter | Behaviour |
|---|---|
search |
OR LIKE %term% across every table column (comma = several terms) |
searchLike |
Incremental filter: field}5 (>=), field{5 (<=), field>5, field<5; plus the whereIn and additionalQueries params |
| neither of them | Exact AND (findAllFieldsAnd) |
limit, page |
Automatic pagination |
order, direction |
Sorting |
fields |
Select only specific columns |
relations |
Eager load (comma-separated) |
// In the controller, this is all of it:
public function index(Request $request): JsonResponse
{
return BaseResponse::paginated($this->service->getData($request));
}
| Artefato | Caminho | Classe |
|---|---|---|
| Controller | Http/Controllers/API/{Folder}/ |
{Entity}ApiController |
| Request criar | Http/Requests/API/{Folder}/ |
Create{Entity}ApiRequest |
| Request atualizar | Http/Requests/API/{Folder}/ |
Update{Entity}ApiRequest |
| Rotas | routes/api.php |
prefixo v1 + middleware da config |
// ❌ NUNCA — query no controller
public function index() {
return Product::where('active', true)->get();
}
// ❌ NUNCA — response()->json() avulso
return response()->json(['data' => $data]);
// ❌ NEVER — business logic in the controller
public function store(Request $request) {
if (Product::where('sku', $request->sku)->exists()) { ... }
}
// ✅ CERTO
public function index(Request $request): JsonResponse
{
return BaseResponse::paginated($this->service->getData($request));
}
Throughput work — caching, queues, indexes, N+1, chunking, Livewire payload — lives in a reference file so it does not cost you tokens on every task:
references/performance.md (read it when the task is actually about
performance; it opens with a measurement of what the package's own layers
cost, so you do not optimise on suspicion).
The three rules worth carrying without reading it:
chunkById.cache:clear.$afterCommit = true, honoured even by the sync driver.⚠️ ALWAYS run Pint before any commit. Never commit unformatted PHP code.
# REQUIRED before every git commit:
./vendor/bin/pint
# Then commit:
git add .
git commit -m "feat: ..."
feat: new feature
fix: bug fix
docs: documentation only
refactor: no feat/fix
test: tests
chore: maintenance (deps, config)
How can I help you explore Laravel packages today?