Skip to content

Database Schema

FORGE generates a complete PostgreSQL database schema with UUID primary keys, proper indexing, soft deletes, and JSONB-based translations. This page serves as the full reference for every table, column, and relationship in a generated application.

Schema Overview

Every FORGE application ships with 19 tables organized into five categories:

+-------------------------------------------------------------------------+
|                       DATABASE ARCHITECTURE                             |
+-------------------------------------------------------------------------+
|                                                                         |
|  CORE (Authentication & Authorization)                                  |
|  users, roles, permissions, role_user, permission_role,                 |
|  password_resets, otp_codes                                             |
|                                                                         |
|  SYSTEM (Configuration & Auditing)                                      |
|  settings, languages, translations, api_keys, audit_logs, sessions      |
|                                                                         |
|  CONTENT (CMS & Navigation)                                             |
|  contents, menus                                                        |
|                                                                         |
|  STORAGE & REFERENCE                                                    |
|  media (polymorphic), lookups (hierarchical)                            |
|                                                                         |
|  PAYMENTS (when payment provider enabled)                               |
|  invoices, invoice_items                                                |
|                                                                         |
+-------------------------------------------------------------------------+

All tables follow these conventions

  • UUID primary keys via gen_random_uuid()
  • Timestamps created_at and updated_at on every table
  • Soft deletes via a nullable deleted_at column where applicable
  • JSONB columns for translations and flexible metadata

Entity Relationship Diagram

                           +-------------+
                           |    users    |
                           +------+------+
                                  |
           +----------------------+---------------------+
           |                      |                     |
           v                      v                     v
   +---------------+      +-------------+      +-----------------+
   |   role_user   |      | audit_logs  |      | password_resets |
   +-------+-------+      +-------------+      +-----------------+
           |
           v
   +---------------+      +--------------------+
   |    roles      |<-----| permission_role    |
   +---------------+      +----------+---------+
                                     |
                                     v
                           +-----------------+
                           |  permissions    |
                           +-----------------+

   +----------+  +----------+  +--------------+  +----------+
   | settings |  | languages|  | translations |  | api_keys |
   +----------+  +----------+  +--------------+  +----------+

   +----------+   +----------+   +----------+   +----------+
   | contents |   |  menus   |   |  media   |   | lookups  |
   +----------+   +----------+   +----------+   +----------+

Core Tables

users

Main user accounts table. Supports both email-based and mobile-based authentication, with soft deletes for safe account deactivation.

ColumnTypeConstraintsDescription
idUUIDPK, default gen_random_uuid()Unique identifier
emailVARCHAR(255)UNIQUE, nullableEmail address
mobileVARCHAR(20)UNIQUE, nullablePhone number
passwordVARCHAR(255)nullableArgon2id hash
remember_tokenVARCHAR(100)nullableSession persistence token
nameVARCHAR(255)NOT NULLDisplay name
avatarVARCHAR(500)nullableAvatar URL or path
is_activeBOOLEANdefault trueAccount enabled flag
email_verified_atTIMESTAMPnullableEmail verification timestamp
mobile_verified_atTIMESTAMPnullableMobile verification timestamp
last_login_atTIMESTAMPnullableLast login timestamp
last_login_ipVARCHAR(45)nullableIPv6-compatible IP
deleted_atTIMESTAMPnullableSoft delete marker
created_atTIMESTAMPdefault NOW()Created timestamp
updated_atTIMESTAMPdefault NOW()Updated timestamp
sql
-- At least one identifier is required
CONSTRAINT users_email_or_mobile CHECK (
    email IS NOT NULL OR mobile IS NOT NULL
);

-- Partial indexes (exclude soft-deleted rows)
CREATE INDEX idx_users_email ON users(email) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_mobile ON users(mobile) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_is_active ON users(is_active) WHERE deleted_at IS NULL;

WARNING

The email and mobile columns are both nullable individually, but the CHECK constraint ensures at least one is always present. Which field is required depends on the auth_method chosen during forge new.

roles

Defines user roles for the RBAC system. System roles (admin, user) cannot be deleted.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
nameVARCHAR(100)NOT NULL, UNIQUEMachine name (admin)
display_nameVARCHAR(255)NOT NULLHuman label (Administrator)
descriptionTEXTnullableRole description
is_systemBOOLEANdefault falsePrevents deletion
created_atTIMESTAMPdefault NOW()Created timestamp
updated_atTIMESTAMPdefault NOW()Updated timestamp
sql
CREATE INDEX idx_roles_name ON roles(name);
CREATE INDEX idx_roles_is_system ON roles(is_system);

permissions

Granular permission records grouped by resource.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
nameVARCHAR(100)NOT NULL, UNIQUEDot-notation key (users.create)
display_nameVARCHAR(255)NOT NULLHuman label (Create Users)
descriptionTEXTnullablePermission description
group_nameVARCHAR(100)NOT NULLGrouping key (users, roles)
is_systemBOOLEANdefault truePrevents deletion
created_atTIMESTAMPdefault NOW()Created timestamp
updated_atTIMESTAMPdefault NOW()Updated timestamp
sql
CREATE INDEX idx_permissions_name ON permissions(name);
CREATE INDEX idx_permissions_group ON permissions(group_name);

role_user (Pivot)

Many-to-many relationship between users and roles.

sql
CREATE TABLE role_user (
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role_id     UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
    assigned_at TIMESTAMP DEFAULT NOW(),
    assigned_by UUID REFERENCES users(id),

    PRIMARY KEY (user_id, role_id)
);

permission_role (Pivot)

Many-to-many relationship between permissions and roles.

sql
CREATE TABLE permission_role (
    permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
    role_id       UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,

    PRIMARY KEY (permission_id, role_id)
);

Auth Tables

password_resets

Stores hashed password-reset tokens with expiration.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
emailVARCHAR(255)NOT NULLTarget email
tokenVARCHAR(255)NOT NULLSHA-256 hashed token
expires_atTIMESTAMPNOT NULLExpiration time
used_atTIMESTAMPnullableWhen the token was consumed
created_atTIMESTAMPdefault NOW()Created timestamp

otp_codes

One-time password codes for mobile authentication and verification flows.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
identifierVARCHAR(255)NOT NULLEmail or mobile number
codeVARCHAR(10)NOT NULL6-digit OTP code
typeVARCHAR(50)NOT NULLlogin, verify, or reset
attemptsINTdefault 0Failed verification count
max_attemptsINTdefault 3Maximum allowed attempts
expires_atTIMESTAMPNOT NULLExpiration time
verified_atTIMESTAMPnullableSuccessful verification time
created_atTIMESTAMPdefault NOW()Created timestamp
sql
CREATE INDEX idx_otp_codes_identifier ON otp_codes(identifier);
CREATE INDEX idx_otp_codes_expires ON otp_codes(expires_at);
CREATE INDEX idx_otp_codes_cleanup ON otp_codes(expires_at)
    WHERE verified_at IS NULL;

DANGER

OTP codes have a strict max_attempts limit. After 3 failed verifications the code is invalidated. Always create a new OTP after exhausting attempts -- never reset the counter.

System Tables

settings

Key-value application settings with type awareness and optional encryption.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
group_nameVARCHAR(100)NOT NULLLogical group (general, email, sms)
keyVARCHAR(255)NOT NULLSetting key (app_name)
valueTEXTnullablePlain text or encrypted value
typeVARCHAR(50)default stringstring, boolean, number, json, encrypted
is_publicBOOLEANdefault falseExposed to frontend API
is_sensitiveBOOLEANdefault falseMasked in admin UI
created_atTIMESTAMPdefault NOW()Created timestamp
updated_atTIMESTAMPdefault NOW()Updated timestamp
sql
UNIQUE (group_name, key)

Encrypted settings

When type = 'encrypted', values are stored as enc:v1:nonce:ciphertext:tag using AES-256-GCM. The encryption key is read from the FORGE_ENCRYPTION_KEY environment variable and is never stored in the database.

languages

Supported languages with RTL direction support.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
codeVARCHAR(10)NOT NULL, UNIQUEISO code (en, ar)
nameVARCHAR(100)NOT NULLEnglish name (Arabic)
native_nameVARCHAR(100)nullableNative name (العربية)
directionVARCHAR(3)default ltrltr or rtl
is_defaultBOOLEANdefault falseDefault language flag
is_activeBOOLEANdefault trueAvailability flag
sort_orderINTdefault 0Display order
created_atTIMESTAMPdefault NOW()Created timestamp
updated_atTIMESTAMPdefault NOW()Updated timestamp
sql
-- Only one default language allowed
CREATE UNIQUE INDEX idx_languages_default ON languages(is_default)
    WHERE is_default = true;

translations

UI translation strings stored as JSONB with all language values in a single row.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
group_nameVARCHAR(100)NOT NULLLogical group (common, auth, admin)
keyVARCHAR(255)NOT NULLTranslation key (login_title)
translationsJSONBNOT NULL{"en": "Sign In", "ar": "تسجيل الدخول"}
created_atTIMESTAMPdefault NOW()Created timestamp
updated_atTIMESTAMPdefault NOW()Updated timestamp
sql
UNIQUE (group_name, key);
CREATE INDEX idx_translations_values ON translations USING GIN (translations);

Default translation groups:

GroupPurposeExample Keys
commonShared UI stringsSave, Cancel, Delete, Confirm
authAuthentication pageslogin_title, register_title
adminAdmin dashboarddashboard_title, sidebar labels
validationForm messagesrequired, min_length
web.homeWeb homepageWelcome_to, App_Description
web.headerSite headerSign_In, Get_Started
web.footerSite footerAll_rights_reserved, Privacy

api_keys

Third-party API key management with rate limiting and revocation.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
nameVARCHAR(255)NOT NULLDescriptive name
key_hashVARCHAR(255)NOT NULLSHA-256 hash of the key
key_prefixVARCHAR(12)NOT NULLFirst 8 chars for identification
permissionsJSONBdefault []Allowed permission names
rate_limitINTdefault 1000Max requests per hour
last_used_atTIMESTAMPnullableLast usage timestamp
last_used_ipVARCHAR(45)nullableLast usage IP
request_countBIGINTdefault 0Total request count
expires_atTIMESTAMPnullableExpiration (NULL = never)
revoked_atTIMESTAMPnullableRevocation timestamp
revoked_byUUIDFK to usersWho revoked the key
revoked_reasonTEXTnullableRevocation reason
created_byUUIDNOT NULL, FK to usersKey creator
created_atTIMESTAMPdefault NOW()Created timestamp
updated_atTIMESTAMPdefault NOW()Updated timestamp

audit_logs

Immutable activity audit trail. Audit logs have no updated_at column -- they are append-only.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
user_idUUIDFK to users, nullableActing user (NULL for system)
user_emailVARCHAR(255)nullableDenormalized email for history
api_key_idUUIDFK to api_keys, nullableIf action was via API key
actionVARCHAR(50)NOT NULLcreated, updated, deleted, login
resource_typeVARCHAR(100)NOT NULLuser, role, setting
resource_idUUIDnullableAffected resource ID
old_valuesJSONBnullablePrevious state
new_valuesJSONBnullableNew state
ip_addressVARCHAR(45)nullableClient IP
user_agentTEXTnullableClient user agent
urlVARCHAR(500)nullableRequest URL
created_atTIMESTAMPdefault NOW()Event timestamp
sql
CREATE INDEX idx_audit_logs_user ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_created ON audit_logs(created_at DESC);
CREATE INDEX idx_audit_logs_filter ON audit_logs(resource_type, action, created_at DESC);

sessions (Optional)

User session tracking for session-based authentication scenarios.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
user_idUUIDNOT NULL, FK to usersSession owner
token_hashVARCHAR(255)NOT NULL, UNIQUEHashed session token
ip_addressVARCHAR(45)nullableClient IP
user_agentTEXTnullableBrowser/client string
device_nameVARCHAR(255)nullableFriendly device name
last_activityTIMESTAMPdefault NOW()Last request time
expires_atTIMESTAMPNOT NULLSession expiration
revoked_atTIMESTAMPnullableManual revocation
created_atTIMESTAMPdefault NOW()Created timestamp

Content Tables

contents

CMS content pages with full translation support, SEO metadata, and scheduled publishing.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
titleVARCHAR(255)NOT NULLDefault language title
slugVARCHAR(255)NOT NULL, UNIQUEURL-friendly identifier
summaryTEXTnullableShort description
detailsTEXTnullableRich HTML content
button_textVARCHAR(100)nullableOptional CTA button
seoJSONBdefault {}{title, description, keywords, robots, canonical}
translationsJSONBdefault {}Per-language overrides for all text fields
is_activeBOOLEANdefault truePublished flag
publish_atTIMESTAMPTZnullableScheduled publish date
expire_atTIMESTAMPTZnullableAuto-unpublish date
sort_orderINTdefault 0Display ordering
created_byUUIDFK to usersAuthor
updated_byUUIDFK to usersLast editor
created_atTIMESTAMPTZdefault NOW()Created timestamp
updated_atTIMESTAMPTZdefault NOW()Updated timestamp
deleted_atTIMESTAMPTZnullableSoft delete marker

Translation JSONB structure:

json
{
  "ar": {
    "title": "من نحن",
    "summary": "نبذة عن الشركة",
    "details": "<p>محتوى الصفحة...</p>",
    "button_text": "اقرأ المزيد",
    "seo": {
      "title": "من نحن | الموقع",
      "description": "وصف الصفحة"
    }
  }
}

Featured images

Content images are not stored as a column on the contents table. Instead, they use the polymorphic media table with model_type='Content' and collection='featured_image'. Use MediaService.attach() and MediaService.get_first_for_model() to manage them.

Navigation menu items with hierarchical structure (parent/child), visibility controls, and translations.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
titleVARCHAR(255)NOT NULLDefault language title
urlVARCHAR(500)nullableLink URL
iconVARCHAR(100)nullableIcon identifier
targetVARCHAR(20)default _self_self or _blank
visibilityVARCHAR(20)default publicpublic, auth, or guest
translationsJSONBdefault {}{"ar": {"title": "الرئيسية"}}
parent_idUUIDFK to menus(id), nullableParent menu item
is_activeBOOLEANdefault trueActive flag
sort_orderINTdefault 0Display ordering
created_atTIMESTAMPTZdefault NOW()Created timestamp
updated_atTIMESTAMPTZdefault NOW()Updated timestamp
deleted_atTIMESTAMPTZnullableSoft delete marker

Visibility options:

ValueWho can see it
publicEveryone (default)
authLogged-in users only
guestNon-logged-in users only

Storage Table

media

Polymorphic file uploads supporting any model type. Follows a Spatie-style pattern where model_type + model_id + collection identify the owner.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
diskVARCHAR(50)default locallocal or s3
pathVARCHAR(500)NOT NULLRelative file path
filenameVARCHAR(255)NOT NULLOriginal file name
mime_typeVARCHAR(100)nullableMIME type
sizeBIGINTnullableFile size in bytes
widthINTnullableImage width (pixels)
heightINTnullableImage height (pixels)
variantsJSONBdefault {}{"thumb": "path/thumb.jpg"}
alt_textVARCHAR(500)nullableAccessibility text
captionTEXTnullableDisplay caption
metadataJSONBdefault {}EXIF data, custom metadata
model_typeVARCHAR(100)nullableOwner model (Content, User)
model_idUUIDnullableOwner record ID
collectionVARCHAR(100)default defaultfeatured_image, avatars
uploaded_byUUIDFK to usersUploader
created_atTIMESTAMPdefault NOW()Created timestamp
updated_atTIMESTAMPdefault NOW()Updated timestamp
sql
-- Polymorphic lookup index
CREATE INDEX idx_media_polymorphic ON media(model_type, model_id, collection)
    WHERE model_type IS NOT NULL;

Example associations:

Content (id: abc-123)  <-- media WHERE model_type='Content',
                            model_id='abc-123', collection='featured_image'

User (id: def-456)     <-- media WHERE model_type='User',
                            model_id='def-456', collection='avatars'

Lookup Table

lookups

Configurable reference data for dropdowns, categories, and hierarchical classifications.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
typeVARCHAR(100)NOT NULLLookup category (country, status)
parent_idUUIDFK to lookups(id), nullableParent for hierarchy
nameVARCHAR(255)NOT NULLDefault language name
valueVARCHAR(255)nullableMachine-readable value
translationsJSONBdefault {}{"ar": {"name": "الكويت"}}
is_activeBOOLEANdefault trueActive flag
sort_orderINTdefault 0Display ordering
metadataJSONBdefault {}Extra data (e.g., country codes)
created_atTIMESTAMPTZdefault NOW()Created timestamp
updated_atTIMESTAMPTZdefault NOW()Updated timestamp
sql
CREATE INDEX idx_lookups_type ON lookups(type) WHERE is_active = true;
CREATE INDEX idx_lookups_parent ON lookups(parent_id);

Use lookups for reusable reference data

Instead of creating a new table for every dropdown (countries, statuses, categories), store them all in lookups with different type values. The parent_id column enables hierarchical data like region > country > city.

Payment Tables

These tables are generated when a payment provider is enabled via forge provider:add payment stripe.

invoices

Invoice records for payment tracking with auto-generated invoice numbers.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
invoice_numberVARCHAR(50)NOT NULL, UNIQUEAuto-generated: INV-YYYYMM-XXXX
customer_idUUIDFK to users, nullableCustomer account
customer_nameVARCHAR(255)nullableCustomer name (denormalized)
customer_emailVARCHAR(255)nullableCustomer email (denormalized)
subtotalDECIMAL(12,2)default 0Sum of line items
tax_amountDECIMAL(12,2)default 0Tax amount
discount_amountDECIMAL(12,2)default 0Discount applied
total_amountDECIMAL(12,2)NOT NULLFinal amount
currencyVARCHAR(3)default USDISO currency code
statusVARCHAR(20)default pendingPayment status
payment_providerVARCHAR(50)nullablestripe, hyperpay, etc.
payment_intent_idVARCHAR(255)nullableProvider's payment ID
payment_methodVARCHAR(50)nullablecard, apple_pay, etc.
card_last4VARCHAR(4)nullableLast 4 digits
card_brandVARCHAR(20)nullablevisa, mastercard, etc.
descriptionTEXTnullableInvoice description
notesTEXTnullableInternal notes
metadataJSONBdefault {}Custom metadata
due_dateTIMESTAMPTZnullablePayment due date
paid_atTIMESTAMPTZnullablePayment timestamp
refunded_atTIMESTAMPTZnullableRefund timestamp
created_atTIMESTAMPTZdefault NOW()Created timestamp
updated_atTIMESTAMPTZdefault NOW()Updated timestamp
sql
-- Indexes
CREATE INDEX idx_invoices_customer ON invoices(customer_id);
CREATE INDEX idx_invoices_status ON invoices(status);
CREATE INDEX idx_invoices_payment_intent ON invoices(payment_intent_id);
CREATE INDEX idx_invoices_number ON invoices(invoice_number);
CREATE INDEX idx_invoices_created ON invoices(created_at DESC);

-- Auto-generate invoice number trigger
CREATE OR REPLACE FUNCTION generate_invoice_number()
RETURNS TRIGGER AS $$
DECLARE
    year_month TEXT;
    seq_num INT;
BEGIN
    year_month := TO_CHAR(NOW(), 'YYYYMM');

    SELECT COALESCE(MAX(
        CAST(SUBSTRING(invoice_number FROM 'INV-' || year_month || '-(\d+)') AS INT)
    ), 0) + 1
    INTO seq_num
    FROM invoices
    WHERE invoice_number LIKE 'INV-' || year_month || '-%';

    NEW.invoice_number := 'INV-' || year_month || '-' || LPAD(seq_num::TEXT, 4, '0');
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER set_invoice_number
    BEFORE INSERT ON invoices
    FOR EACH ROW
    WHEN (NEW.invoice_number IS NULL)
    EXECUTE FUNCTION generate_invoice_number();

Invoice Status Flow:

pending → processing → paid → refunded
    ↓         ↓          ↓
 cancelled  failed   partially_refunded
StatusDescription
pendingInvoice created, awaiting payment
processingPayment in progress
paidPayment successful
failedPayment failed
cancelledInvoice cancelled before payment
refundedFull refund processed
partially_refundedPartial refund processed

invoice_items

Line items for invoices with automatic total calculation.

ColumnTypeConstraintsDescription
idUUIDPKUnique identifier
invoice_idUUIDNOT NULL, FK to invoicesParent invoice
descriptionVARCHAR(500)NOT NULLItem description
quantityINTEGERdefault 1Item quantity
unit_priceDECIMAL(12,2)NOT NULLPrice per unit
amountDECIMAL(12,2)NOT NULLquantity * unit_price
product_idUUIDnullableOptional product reference
product_typeVARCHAR(100)nullableProduct model type
created_atTIMESTAMPTZdefault NOW()Created timestamp
updated_atTIMESTAMPTZdefault NOW()Updated timestamp
sql
CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id);

-- Auto-calculate amount and update invoice totals
CREATE OR REPLACE FUNCTION update_invoice_totals()
RETURNS TRIGGER AS $$
BEGIN
    -- Calculate item amount
    IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
        NEW.amount := NEW.quantity * NEW.unit_price;
    END IF;

    -- Update invoice subtotal and total
    UPDATE invoices
    SET subtotal = (
            SELECT COALESCE(SUM(amount), 0)
            FROM invoice_items
            WHERE invoice_id = COALESCE(NEW.invoice_id, OLD.invoice_id)
        ),
        total_amount = subtotal + tax_amount - discount_amount,
        updated_at = NOW()
    WHERE id = COALESCE(NEW.invoice_id, OLD.invoice_id);

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER calculate_invoice_item_amount
    BEFORE INSERT OR UPDATE ON invoice_items
    FOR EACH ROW
    EXECUTE FUNCTION update_invoice_totals();

CREATE TRIGGER recalculate_invoice_on_item_delete
    AFTER DELETE ON invoice_items
    FOR EACH ROW
    EXECUTE FUNCTION update_invoice_totals();

Cascading totals

When invoice items are added, updated, or deleted, triggers automatically recalculate the invoice's subtotal and total_amount. You don't need to manually update these values.

Index Strategy

FORGE applies several indexing patterns to optimize query performance:

PatternExamplePurpose
Partial indexesWHERE deleted_at IS NULLSkip soft-deleted rows
Composite indexes(resource_type, action, created_at)Multi-column filters
GIN indexesUSING GIN (translations)JSONB containment queries
Unique partialWHERE is_default = trueEnforce single default
Descendingcreated_at DESCNewest-first ordering

JSONB Patterns

FORGE uses JSONB columns in three distinct patterns:

1. Inline Translations

Used by contents, menus, and lookups to store per-language text inline:

sql
-- Get Arabic title
SELECT translations->'ar'->>'title' FROM contents WHERE slug = 'about';

2. Translation Bundles

Used by the translations table to store UI strings:

sql
-- Get all Arabic auth translations
SELECT key, translations->>'ar' AS value
FROM translations
WHERE group_name = 'auth';

3. Structured Metadata

Used by contents.seo, media.metadata, and api_keys.permissions:

sql
-- Find content with specific SEO robots setting
SELECT * FROM contents
WHERE seo->>'robots' = 'noindex';

Migration Order

Migrations run in dependency order. FORGE generates numbered migration files:

00001  create_languages_table
00002  create_users_table
00003  create_roles_table
00004  create_permissions_table
00005  create_role_user_table
00006  create_permission_role_table
00007  create_password_resets_table
00008  create_otp_codes_table
00009  create_settings_table
00010  create_translations_table
00011  create_api_keys_table
00012  create_audit_logs_table
00013  create_media_table
00014  create_contents_table
00015  create_menus_table
00016  migrate_media_polymorphic
00017  create_lookups_table
00018  create_invoices_table      (when payments enabled)
00019  create_invoice_items_table (when payments enabled)

After migrations, seeders populate default data in this order:

01  seed_permissions (all permission groups)
02  seed_roles (admin, user)
03  seed_users (admin@{app}.test, user@{app}.test)
04  seed_languages (en, ar)
05  seed_settings (general, auth, email, storage)
06  seed_translations (common, auth, admin, web.*)

Useful Queries

Get user with roles and permissions

sql
SELECT
    u.id, u.email, u.name,
    array_agg(DISTINCT r.name) AS roles,
    array_agg(DISTINCT p.name) AS permissions
FROM users u
LEFT JOIN role_user ru ON u.id = ru.user_id
LEFT JOIN roles r ON ru.role_id = r.id
LEFT JOIN permission_role pr ON r.id = pr.role_id
LEFT JOIN permissions p ON pr.permission_id = p.id
WHERE u.id = $1
GROUP BY u.id, u.email, u.name;

Check if user has a specific permission

sql
SELECT EXISTS (
    SELECT 1
    FROM users u
    JOIN role_user ru ON u.id = ru.user_id
    JOIN permission_role pr ON ru.role_id = pr.role_id
    JOIN permissions p ON pr.permission_id = p.id
    WHERE u.id = $1 AND p.name = $2
) AS has_permission;

Get all translations for a language

sql
SELECT group_name, key, translations->>$1 AS value
FROM translations
WHERE translations ? $1;
-- $1 = language code, e.g. 'ar'

Audit log summary (last 7 days)

sql
SELECT
    u.email, al.action, al.resource_type,
    COUNT(*) AS event_count
FROM audit_logs al
JOIN users u ON al.user_id = u.id
WHERE al.created_at > NOW() - INTERVAL '7 days'
GROUP BY u.email, al.action, al.resource_type
ORDER BY event_count DESC;

See Also

Released under the MIT License.