Skip to content

🔧 Backend & Admin

This guide covers everything you need to know about the LIPAIX backend, built with PayloadCMS - a modern, TypeScript-first headless CMS that provides both an admin interface and powerful APIs.

🎯 Overview

What is the Backend?

The LIPAIX Backend is built with PayloadCMS, a self-hosted headless CMS that provides:

  • 📊 Admin Interface - Visual content management system
  • 🔌 REST & GraphQL APIs - Programmatic access to data
  • 🗄️ Database Management - Automatic schema generation and migrations
  • 🔐 Access Control - Role-based permissions and authentication
  • 🌐 Internationalization - Multi-language content support
  • 📝 Content Types - Flexible field definitions and relationships

Why PayloadCMS?

  1. 🚀 TypeScript-First - Full type safety and IntelliSense
  2. 🎨 Self-Hosted - Complete control over your data and infrastructure
  3. 🔌 API-First - REST and GraphQL APIs out of the box
  4. 📱 Admin Interface - Beautiful, customizable admin panel
  5. 🔄 Real-time - Live preview and collaborative editing
  6. 📊 Rich Content - Rich text, media, and relationship fields

🏗️ Architecture Overview

Backend Architecture

mermaid
graph TB
    subgraph "PayloadCMS Core"
        CONFIG[Payload Config]
        COLLECTIONS[Collections]
        FIELDS[Field Types]
        HOOKS[Hooks & Lifecycles]
    end
    
    subgraph "Admin Interface"
        ADMIN_UI[Admin UI]
        FORMS[Forms & Fields]
        MEDIA[Media Library]
        USERS[User Management]
    end
    
    subgraph "API Layer"
        REST_API[REST API]
        GRAPHQL[GraphQL API]
        AUTH[Authentication]
        VALIDATION[Validation]
    end
    
    subgraph "Database"
        POSTGRES[(PostgreSQL)]
        MIGRATIONS[Migrations]
        INDEXES[Indexes]
    end
    
    CONFIG --> COLLECTIONS
    COLLECTIONS --> FIELDS
    FIELDS --> HOOKS
    
    COLLECTIONS --> ADMIN_UI
    FIELDS --> FORMS
    HOOKS --> MEDIA
    
    COLLECTIONS --> REST_API
    COLLECTIONS --> GRAPHQL
    HOOKS --> AUTH
    FIELDS --> VALIDATION
    
    COLLECTIONS --> POSTGRES
    HOOKS --> MIGRATIONS
    VALIDATION --> INDEXES

Data Flow

mermaid
sequenceDiagram
    participant A as Admin User
    participant UI as Admin UI
    participant API as PayloadCMS API
    participant DB as PostgreSQL
    
    A->>UI: Create/Edit Content
    UI->>API: Submit Form Data
    API->>API: Validate & Transform
    API->>DB: Save to Database
    DB-->>API: Confirmation
    API-->>UI: Success Response
    UI-->>A: Content Updated

📊 Database Collections

What are Collections?

Collections in PayloadCMS are like database tables, but with much more power. They define the structure of your data and automatically create database tables, admin forms, and API endpoints.

LIPAIX Collections

Show Collection (Events)

typescript
export const Show: CollectionConfig = {
  slug: 'shows',
  admin: {
    useAsTitle: 'title',
    group: 'Événements',
    defaultColumns: ['title', 'date', 'venue', 'status'],
  },
  access: {
    read: () => true, // Public read access
    create: ({ req: { user } }) => Boolean(user?.role === 'admin' || user?.role === 'editor'),
    update: ({ req: { user } }) => Boolean(user?.role === 'admin' || user?.role === 'editor'),
    delete: ({ req: { user } }) => Boolean(user?.role === 'admin'),
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
      localized: true, // French and English versions
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
      admin: {
        description: 'URL slug for the event page',
      },
    },
    {
      name: 'date',
      type: 'date',
      required: true,
      admin: {
        date: {
          pickerAppearance: 'dayAndTime',
        },
      },
    },
    {
      name: 'eventType',
      type: 'select',
      required: true,
      options: [
        { label: 'Apéro Impro', value: 'apero-impro' },
        { label: 'Match d\'Impro', value: 'match-home' },
        { label: 'Match Extérieur', value: 'match-away' },
        { label: 'New Impro Show', value: 'new-impro-show' },
        { label: 'Toute(s) une Histoire', value: 'histoire' },
        { label: 'La Main dans le Sac', value: 'main-dans-le-sac' },
        { label: 'Public Investigation', value: 'public-investigation' },
        { label: 'Battle d\'Impro', value: 'battle' },
        { label: 'Festival', value: 'festival' },
        { label: 'Autre', value: 'other' },
      ],
    },
    {
      name: 'descriptions',
      type: 'group',
      fields: [
        {
          name: 'headline',
          type: 'text',
          localized: true,
          admin: {
            description: 'Titre accrocheur pour l\'affichage',
          },
        },
        {
          name: 'short',
          type: 'textarea',
          localized: true,
          admin: {
            description: 'Description courte (max 200 caractères)',
          },
        },
        {
          name: 'detailed',
          type: 'richText',
          localized: true,
          admin: {
            description: 'Description détaillée avec mise en forme',
          },
        },
      ],
    },
    {
      name: 'venue',
      type: 'relationship',
      relationTo: 'venues',
      required: true,
    },
    {
      name: 'media',
      type: 'group',
      fields: [
        {
          name: 'poster',
          type: 'upload',
          relationTo: 'media',
          admin: {
            description: 'Affiche de l\'événement',
          },
        },
        {
          name: 'thumbnail',
          type: 'upload',
          relationTo: 'media',
          admin: {
            description: 'Miniature de l\'événement',
          },
        },
        {
          name: 'gallery',
          type: 'array',
          fields: [
            {
              name: 'image',
              type: 'upload',
              relationTo: 'media',
            },
          ],
          admin: {
            description: 'Galerie photos de l\'événement',
          },
        },
      ],
    },
    {
      name: 'status',
      type: 'select',
      required: true,
      defaultValue: 'draft',
      options: [
        { label: 'Brouillon', value: 'draft' },
        { label: 'Publié', value: 'published' },
        { label: 'Archivé', value: 'archived' },
      ],
    },
  ],
  hooks: {
    beforeChange: [
      ({ data }) => {
        // Auto-generate slug if not provided
        if (!data.slug && data.title) {
          data.slug = data.title
            .toLowerCase()
            .replace(/[^a-z0-9]+/g, '-')
            .replace(/(^-|-$)/g, '')
        }
        return data
      },
    ],
  },
}

Availability Collection

typescript
export const Availability: CollectionConfig = {
  slug: 'availabilities',
  admin: {
    useAsTitle: 'playerName',
    group: 'Disponibilités',
    defaultColumns: ['playerName', 'event', 'status', 'updatedAt'],
  },
  access: {
    read: ({ req: { user } }) => {
      // Users can read their own availabilities
      if (user) return true
      return false
    },
    create: ({ req: { user } }) => Boolean(user),
    update: ({ req: { user }, data }) => {
      // Users can update their own availabilities
      if (user?.id === data.player) return true
      // Admins can update any
      if (user?.role === 'admin') return true
      return false
    },
    delete: ({ req: { user } }) => Boolean(user?.role === 'admin'),
  },
  fields: [
    {
      name: 'player',
      type: 'relationship',
      relationTo: 'users',
      required: true,
      admin: {
        description: 'Joueur',
      },
    },
    {
      name: 'event',
      type: 'relationship',
      relationTo: 'shows',
      required: true,
      admin: {
        description: 'Événement',
      },
    },
    {
      name: 'status',
      type: 'select',
      required: true,
      options: [
        { label: 'Disponible', value: 'available' },
        { label: 'Indisponible', value: 'unavailable' },
        { label: 'Peut-être', value: 'maybe' },
        { label: 'Non répondu', value: 'unanswered' },
      ],
      defaultValue: 'unanswered',
    },
    {
      name: 'notes',
      type: 'textarea',
      admin: {
        description: 'Notes additionnelles (optionnel)',
      },
    },
    {
      name: 'updatedAt',
      type: 'date',
      admin: {
        readOnly: true,
        position: 'sidebar',
      },
    },
  ],
}

🎨 Field Types

Core Field Types

PayloadCMS provides a rich set of field types for building complex content structures:

mermaid
graph TB
    subgraph "Basic Fields"
        TEXT[Text]
        TEXTAREA[Textarea]
        NUMBER[Number]
        EMAIL[Email]
        URL[URL]
        DATE[Date]
        CHECKBOX[Checkbox]
        SELECT[Select]
    end
    
    subgraph "Rich Content"
        RICHTEXT[Rich Text]
        UPLOAD[Upload]
        ARRAY[Array]
        GROUP[Group]
        TAB[Tab]
        ROW[Row]
    end
    
    subgraph "Relationships"
        RELATIONSHIP[Relationship]
        BLOCKS[Blocks]
        COLLAPSIBLE[Collapsible]
        TABS[Tabs]
    end
    
    TEXT --> RICHTEXT
    TEXTAREA --> UPLOAD
    NUMBER --> ARRAY
    SELECT --> GROUP
    CHECKBOX --> TAB
    DATE --> ROW
    RELATIONSHIP --> BLOCKS
    BLOCKS --> COLLAPSIBLE
    COLLAPSIBLE --> TABS

Field Configuration Examples

typescript
import { Field } from 'payload/types'

export const descriptionsField: Field = {
  name: 'descriptions',
  type: 'group',
  fields: [
    {
      name: 'headline',
      type: 'text',
      localized: true,
      admin: {
        description: 'Titre accrocheur pour l\'affichage',
        placeholder: 'Ex: Un spectacle d\'improvisation unique',
      },
      validate: (val) => {
        if (val && val.length > 100) {
          return 'Le titre ne peut pas dépasser 100 caractères'
        }
        return true
      },
    },
    {
      name: 'short',
      type: 'textarea',
      localized: true,
      admin: {
        description: 'Description courte (max 200 caractères)',
        rows: 3,
      },
      validate: (val) => {
        if (val && val.length > 200) {
          return 'La description courte ne peut pas dépasser 200 caractères'
        }
        return true
      },
    },
    {
      name: 'detailed',
      type: 'richText',
      localized: true,
      admin: {
        description: 'Description détaillée avec mise en forme',
        elements: ['h2', 'h3', 'link', 'ol', 'ul'],
        leaves: ['bold', 'italic', 'underline'],
      },
    },
  ],
}

🎭 Custom Admin Components

Component Architecture

PayloadCMS allows you to create custom admin components for enhanced user experience:

mermaid
graph LR
    subgraph "Custom Components"
        FIELD_COMPONENT[Field Component]
        CELL_COMPONENT[Cell Component]
        VIEW_COMPONENT[View Component]
        HOOK_COMPONENT[Hook Component]
    end
    
    subgraph "Integration Points"
        FIELD_OVERRIDE[Field Override]
        CELL_OVERRIDE[Cell Override]
        VIEW_OVERRIDE[View Override]
        HOOK_OVERRIDE[Hook Override]
    end
    
    subgraph "Benefits"
        BETTER_UX[Better UX]
        CUSTOM_VALIDATION[Custom Validation]
        ENHANCED_FUNCTIONALITY[Enhanced Functionality]
        BRANDING[Branding]
    end
    
    FIELD_COMPONENT --> FIELD_OVERRIDE
    CELL_COMPONENT --> CELL_OVERRIDE
    VIEW_COMPONENT --> VIEW_OVERRIDE
    HOOK_COMPONENT --> HOOK_OVERRIDE
    
    FIELD_OVERRIDE --> BETTER_UX
    CELL_OVERRIDE --> CUSTOM_VALIDATION
    VIEW_OVERRIDE --> ENHANCED_FUNCTIONALITY
    HOOK_OVERRIDE --> BRANDING

Custom Field Component Example

typescript
import React from 'react'
import { useField } from 'payload/components/forms'
import { Label } from 'payload/components/forms'

interface HeadlineFieldProps {
  path: string
  label?: string
  required?: boolean
}

export const HeadlineField: React.FC<HeadlineFieldProps> = ({ 
  path, 
  label = 'Titre accrocheur', 
  required = false 
}) => {
  const { value, setValue } = useField<string>({ path })
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = e.target.value
    setValue(newValue)
  }
  
  const getCharacterCount = () => {
    return value ? value.length : 0
  }
  
  const getCharacterCountColor = () => {
    const count = getCharacterCount()
    if (count > 80) return 'text-red-500'
    if (count > 60) return 'text-yellow-500'
    return 'text-gray-500'
  }
  
  return (
    <div className="field-type headline-field">
      <Label 
        htmlFor={path} 
        label={label} 
        required={required} 
      />
      <input
        id={path}
        type="text"
        value={value || ''}
        onChange={handleChange}
        className="input"
        placeholder="Ex: Un spectacle d'improvisation unique"
        maxLength={100}
      />
      <div className={`character-count ${getCharacterCountColor()}`}>
        {getCharacterCount()}/100 caractères
      </div>
      {value && value.length > 80 && (
        <div className="warning text-yellow-600 text-sm mt-1">
          ⚠️ Le titre est assez long, envisagez de le raccourcir
        </div>
      )}
    </div>
  )
}

🔌 API Development

API Architecture

PayloadCMS automatically generates REST and GraphQL APIs for all your collections:

mermaid
graph TB
    subgraph "API Endpoints"
        REST[REST API]
        GRAPHQL[GraphQL API]
        WEBHOOKS[Webhooks]
    end
    
    subgraph "Authentication"
        JWT[JWT Tokens]
        API_KEYS[API Keys]
        SESSIONS[Sessions]
    end
    
    subgraph "Features"
        FILTERING[Filtering]
        SORTING[Sorting]
        PAGINATION[Pagination]
        RELATIONS[Relations]
    end
    
    REST --> JWT
    GRAPHQL --> API_KEYS
    WEBHOOKS --> SESSIONS
    
    JWT --> FILTERING
    API_KEYS --> SORTING
    SESSIONS --> PAGINATION
    FILTERING --> RELATIONS

REST API Examples

typescript
import { payload } from 'payload'

// Get all published events
export async function getPublishedEvents() {
  const events = await payload.find({
    collection: 'shows',
    where: {
      status: { equals: 'published' },
    },
    sort: 'date',
    limit: 10,
  })
  
  return events
}

// Get event by slug with related data
export async function getEventBySlug(slug: string) {
  const event = await payload.find({
    collection: 'shows',
    where: {
      slug: { equals: slug },
      status: { equals: 'published' },
    },
    depth: 2, // Include related data
    populate: ['venue', 'media.poster', 'media.thumbnail'],
  })
  
  return event.docs[0] || null
}

// Get upcoming events
export async function getUpcomingEvents(limit = 5) {
  const now = new Date()
  
  const events = await payload.find({
    collection: 'shows',
    where: {
      and: [
        { status: { equals: 'published' } },
        { date: { greater_than: now.toISOString() } },
      ],
    },
    sort: 'date',
    limit,
  })
  
  return events
}

GraphQL API Examples

typescript
import { gql } from 'graphql-request'

const GET_EVENTS_QUERY = gql`
  query GetEvents($status: Shows_Status_Input, $limit: Int) {
    Shows(where: { status: { equals: $status } }, limit: $limit) {
      docs {
        id
        title
        slug
        date
        eventType
        descriptions {
          headline
          short
        }
        venue {
          name
          address {
            city
          }
        }
        media {
          poster {
            url
            alt
          }
          thumbnail {
            url
            alt
          }
        }
        status
      }
      totalDocs
      totalPages
      page
      hasNextPage
      hasPrevPage
    }
  }
`

const GET_EVENT_BY_SLUG_QUERY = gql`
  query GetEventBySlug($slug: String!) {
    Shows(where: { slug: { equals: $slug } }) {
      docs {
        id
        title
        slug
        date
        eventType
        descriptions {
          headline
          short
          detailed
        }
        venue {
          name
          address {
            street
            city
            postalCode
          }
        }
        media {
          poster {
            url
            alt
          }
          thumbnail {
            url
            alt
          }
          gallery {
            image {
              url
              alt
            }
          }
        }
        status
      }
    }
  }
`

export async function getEventsGraphQL(status = 'published', limit = 10) {
  const variables = { status, limit }
  const data = await graphqlClient.request(GET_EVENTS_QUERY, variables)
  return data.Shows
}

export async function getEventBySlugGraphQL(slug: string) {
  const variables = { slug }
  const data = await graphqlClient.request(GET_EVENT_BY_SLUG_QUERY, variables)
  return data.Shows.docs[0] || null
}

🔐 Access Control

Access Control System

PayloadCMS provides a powerful access control system based on user roles and permissions:

mermaid
graph TB
    subgraph "Access Control"
        ROLES[User Roles]
        PERMISSIONS[Permissions]
        POLICIES[Access Policies]
    end
    
    subgraph "Collection Access"
        READ[Read Access]
        CREATE[Create Access]
        UPDATE[Update Access]
        DELETE[Delete Access]
    end
    
    subgraph "Field Access"
        FIELD_READ[Field Read]
        FIELD_UPDATE[Field Update]
        FIELD_CREATE[Field Create]
    end
    
    ROLES --> PERMISSIONS
    PERMISSIONS --> POLICIES
    
    POLICIES --> READ
    POLICIES --> CREATE
    POLICIES --> UPDATE
    POLICIES --> DELETE
    
    READ --> FIELD_READ
    UPDATE --> FIELD_UPDATE
    CREATE --> FIELD_CREATE

Access Control Examples

typescript
import { Access, FieldAccess } from 'payload/types'
import { User } from '../payload-types'

// Collection-level access control
export const isAdmin: Access = ({ req: { user } }) => {
  return Boolean(user?.role === 'admin')
}

export const isAdminOrEditor: Access = ({ req: { user } }) => {
  return Boolean(user?.role === 'admin' || user?.role === 'editor')
}

export const isAdminOrSelf: Access = ({ req: { user }, data }) => {
  if (user?.role === 'admin') return true
  if (user?.id === data?.user) return true
  return false
}

// Field-level access control
export const isAdminFieldLevel: FieldAccess = ({ req: { user } }) => {
  return Boolean(user?.role === 'admin')
}

export const isAdminOrEditorFieldLevel: FieldAccess = ({ req: { user } }) => {
  return Boolean(user?.role === 'admin' || user?.role === 'editor')
}

// Conditional access based on data
export const canEditEvent: Access = ({ req: { user }, data }) => {
  if (user?.role === 'admin') return true
  if (user?.role === 'editor') return true
  
  // Users can edit their own events
  if (data?.createdBy === user?.id) return true
  
  return false
}

// Time-based access control
export const canEditPublishedEvent: Access = ({ req: { user }, data }) => {
  if (user?.role === 'admin') return true
  if (user?.role === 'editor') return true
  
  // Published events can only be edited by admins/editors
  if (data?.status === 'published') {
    return Boolean(user?.role === 'admin' || user?.role === 'editor')
  }
  
  // Draft events can be edited by creators
  if (data?.createdBy === user?.id) return true
  
  return false
}

🌐 Internationalization

i18n Architecture

LIPAIX supports multiple languages through PayloadCMS's built-in internationalization:

mermaid
graph LR
    subgraph "Languages"
        FR[Français]
        EN[English]
        ES[Español]
    end
    
    subgraph "Content Types"
        LOCALIZED[Localized Fields]
        FALLBACK[Fallback Language]
        TRANSLATIONS[Translation Management]
    end
    
    subgraph "Admin Interface"
        LANGUAGE_SWITCHER[Language Switcher]
        TRANSLATION_UI[Translation UI]
        CONTENT_PREVIEW[Content Preview]
    end
    
    FR --> LOCALIZED
    EN --> FALLBACK
    ES --> TRANSLATIONS
    
    LOCALIZED --> LANGUAGE_SWITCHER
    FALLBACK --> TRANSLATION_UI
    TRANSLATIONS --> CONTENT_PREVIEW

i18n Configuration

typescript
import { buildConfig } from 'payload/config'

export default buildConfig({
  // ... other config
  localization: {
    locales: ['fr', 'en', 'es'],
    defaultLocale: 'fr',
    fallback: true, // Use fallback locale if translation missing
  },
  collections: [
    {
      slug: 'shows',
      fields: [
        {
          name: 'title',
          type: 'text',
          localized: true, // This field will be translated
          required: true,
        },
        {
          name: 'descriptions',
          type: 'group',
          fields: [
            {
              name: 'headline',
              type: 'text',
              localized: true,
            },
            {
              name: 'short',
              type: 'textarea',
              localized: true,
            },
            {
              name: 'detailed',
              type: 'richText',
              localized: true,
            },
          ],
        },
        {
          name: 'venue',
          type: 'relationship',
          relationTo: 'venues',
          // Not localized - same for all languages
        },
      ],
    },
  ],
})

🌱 Database Seeding

Seeding Strategy

Database seeding provides initial data for development and testing:

mermaid
graph TD
    A[Seed Scripts] --> B[Initial Data]
    B --> C[Development Environment]
    B --> D[Testing Environment]
    B --> E[Staging Environment]
    
    subgraph "Seed Types"
        USERS[Users & Roles]
        CONTENT[Content Types]
        MEDIA[Media Files]
        RELATIONS[Relationships]
    end
    
    C --> USERS
    D --> CONTENT
    E --> MEDIA
    USERS --> RELATIONS

Seeding Implementation

typescript
import { payload } from 'payload'
import { seedRoles } from './seed-roles'
import { seedUsers } from './seed-users'
import { seedVenues } from './seed-venues'
import { seedEventTypes } from './seed-event-types'

export async function runInitialSeed() {
  try {
    console.log('🌱 Starting initial seed...')
    
    // Seed in order due to dependencies
    await seedRoles()
    await seedUsers()
    await seedVenues()
    await seedEventTypes()
    
    console.log('✅ Initial seed completed successfully!')
  } catch (error) {
    console.error('❌ Seed failed:', error)
    throw error
  }
}

// Seed event types
async function seedEventTypes() {
  const eventTypes = [
    { label: 'Apéro Impro', value: 'apero-impro' },
    { label: 'Match d\'Impro', value: 'match-home' },
    { label: 'Match Extérieur', value: 'match-away' },
    { label: 'New Impro Show', value: 'new-impro-show' },
    { label: 'Toute(s) une Histoire', value: 'histoire' },
    { label: 'La Main dans le Sac', value: 'main-dans-le-sac' },
    { label: 'Public Investigation', value: 'public-investigation' },
    { label: 'Battle d\'Impro', value: 'battle' },
    { label: 'Festival', value: 'festival' },
    { label: 'Autre', value: 'other' },
  ]
  
  for (const eventType of eventTypes) {
    const existing = await payload.find({
      collection: 'event-types',
      where: { value: { equals: eventType.value } },
    })
    
    if (existing.docs.length === 0) {
      await payload.create({
        collection: 'event-types',
        data: eventType,
      })
      console.log(`✅ Created event type: ${eventType.label}`)
    }
  }
}

🛠️ Development Tools

Development Workflow

mermaid
graph LR
    subgraph "Development"
        CODE[Code Changes]
        HOT_RELOAD[Hot Reload]
        AUTO_REBUILD[Auto Rebuild]
    end
    
    subgraph "Testing"
        UNIT_TESTS[Unit Tests]
        INTEGRATION[Integration Tests]
        E2E_TESTS[E2E Tests]
    end
    
    subgraph "Deployment"
        DEV[Development]
        STAGING[Staging]
        PRODUCTION[Production]
    end
    
    CODE --> HOT_RELOAD
    HOT_RELOAD --> AUTO_REBUILD
    AUTO_REBUILD --> UNIT_TESTS
    UNIT_TESTS --> INTEGRATION
    INTEGRATION --> E2E_TESTS
    E2E_TESTS --> DEV
    DEV --> STAGING
    STAGING --> PRODUCTION

Development Scripts

json
{
  "scripts": {
    "dev": "payload dev",
    "build": "payload build",
    "serve": "payload serve",
    "seed": "tsx src/admin/seeds/initialSeed.ts",
    "seed:reset": "tsx src/admin/seeds/resetDatabase.ts",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "lint": "eslint src/**/*.ts",
    "lint:fix": "eslint src/**/*.ts --fix",
    "type-check": "tsc --noEmit"
  }
}

🔧 Backend & Admin Summary

The LIPAIX backend is built with PayloadCMS, a modern TypeScript-first headless CMS that provides both an admin interface and powerful APIs. The system includes comprehensive access control, internationalization, and automated database management.

Key features:

  • Collections - Flexible data structures with automatic API generation
  • Admin Interface - Beautiful, customizable content management system
  • API Layer - REST and GraphQL APIs with authentication and validation
  • Access Control - Role-based permissions and field-level security
  • Internationalization - Multi-language content support
  • Development Tools - Hot reload, seeding, and comprehensive testing

Released under the MIT License.