Skip to content

The Content Model ​

The content model is what the school's staff will fill in: a course has a level, hours, a price and a curriculum; a session has a date, a place and seats. This chapter writes it as a seeder, so it is code you can review, run on a fresh database and commit.

Two languages ​

Lynx starts with one language, default. Give it its real code, then add Portuguese, which falls back to English for anything not translated yet:

php
lynx()->languages->update('default', ['code' => 'en', 'name' => 'English', 'locale' => 'en']);
lynx()->languages->add('pt', 'Português', locale: 'pt-PT', fallback: 'en');

Fields ​

A field is created once and shared by every template that uses it. Text that visitors read is translatable, one value per language; numbers, dates and choices are the same in both languages.

php
// Text that visitors read in their language
lynx()->fields->add('summary', 'textarea', translatable: true, label: 'Summary');
lynx()->fields->add('body', 'textarea', translatable: true, label: 'Body', settings: ['format' => 'html']);
lynx()->fields->add('heading', 'text', translatable: true, label: 'Heading');
lynx()->fields->add('text', 'textarea', translatable: true, label: 'Text');

// Courses
lynx()->fields->add('level', 'choice', label: 'Level', settings: ['input' => 'radios', 'options' => [
    ['value' => 'beginner', 'label' => ['en' => 'Beginner', 'pt' => 'Iniciante']],
    ['value' => 'intermediate', 'label' => ['en' => 'Intermediate', 'pt' => 'Intermédio']],
    ['value' => 'advanced', 'label' => ['en' => 'Advanced', 'pt' => 'Avançado']],
]]);
lynx()->fields->add('hours', 'integer', label: 'Hours of teaching');
lynx()->fields->add('price', 'decimal', label: 'Price (EUR)');
lynx()->fields->add('curriculum', 'repeater', label: 'Curriculum', settings: [
    'fields' => [['name' => 'heading', 'required' => true], ['name' => 'text']],
    'item_label' => '{heading}',
    'add_label' => 'Add a module',
]);
lynx()->fields->add('instructors', 'page', label: 'Instructors', settings: ['templates' => ['instructor']]);
lynx()->fields->add('photos', 'images', label: 'Photos', settings: ['max_files' => 6]);

// Sessions: one dated run of a course, which people book
lynx()->fields->add('starts', 'date', label: 'Starts', settings: ['time' => true]);
lynx()->fields->add('location', 'page', label: 'Location', settings: ['multiple' => false, 'templates' => ['location'], 'input' => 'select']);
lynx()->fields->add('seats', 'integer', label: 'Seats left');
lynx()->fields->add('availability', 'choice', label: 'Availability', settings: ['input' => 'radios', 'options' => [
    ['value' => 'scheduled', 'label' => ['en' => 'Open for booking', 'pt' => 'Inscrições abertas']],
    ['value' => 'cancelled', 'label' => ['en' => 'Cancelled', 'pt' => 'Cancelada']],
]]);

// Locations
lynx()->fields->add('street', 'text', label: 'Street');
lynx()->fields->add('city', 'text', label: 'City');
lynx()->fields->add('postal_code', 'text', label: 'Postal code');

// The home page's sections
lynx()->fields->add('icon', 'icon', label: 'Icon');
lynx()->fields->add('figure', 'text', label: 'Figure');
lynx()->fields->add('person', 'text', label: 'Person');
lynx()->fields->add('rating', 'integer', label: 'Rating (1 to 5)');
lynx()->fields->add('highlights', 'repeater', label: 'Highlights', settings: [
    'fields' => [['name' => 'icon'], ['name' => 'heading', 'required' => true], ['name' => 'text']], 'max' => 6, 'item_label' => '{heading}',
]);
lynx()->fields->add('stats', 'repeater', label: 'Figures', settings: [
    'fields' => [['name' => 'figure', 'required' => true], ['name' => 'heading', 'label' => 'Caption']], 'max' => 4, 'item_label' => '{figure} {heading}',
]);
lynx()->fields->add('steps', 'repeater', label: 'How it works', settings: [
    'fields' => [['name' => 'heading', 'required' => true], ['name' => 'text']], 'max' => 4, 'item_label' => '{heading}',
]);
lynx()->fields->add('testimonials', 'repeater', label: 'Testimonials', settings: [
    'fields' => [['name' => 'text', 'label' => 'Quote', 'required' => true], ['name' => 'person', 'required' => true], ['name' => 'rating']], 'item_label' => '{person}',
]);
lynx()->fields->add('faqs', 'repeater', label: 'Questions', settings: [
    'fields' => [['name' => 'heading', 'label' => 'Question', 'required' => true], ['name' => 'text', 'label' => 'Answer', 'required' => true]], 'item_label' => '{heading}',
]);
lynx()->fields->add('featured', 'page', label: 'Featured courses', settings: ['templates' => ['course']]);

// Site-wide details, on the settings page
lynx()->fields->add('email', 'email', label: 'Email');
lynx()->fields->add('phone', 'text', label: 'Phone');

// News
lynx()->fields->add('published_on', 'date', label: 'Published on');

// Course materials (chapter 8): private, so only the people who booked can download them
lynx()->fields->add('downloads', 'files', label: 'Downloads', settings: ['private' => true, 'extensions' => 'pdf zip mp3 mp4']);

A few choices worth explaining:

  • level is a choice, not text. Pages store the value (beginner), and visitors read the label in their language (Iniciante). Filters compare values, so renaming a label never breaks a link.
  • The home page's sections are repeaters. Highlights, figures, steps, testimonials and questions are lists the staff edit, sort and switch off in the admin, without a developer.
  • Repeaters reuse fields. heading and text appear in several repeaters with different labels (Question, Answer), so there are fewer tables and one way to read them.
  • The session's state is availability. status is taken, because pages already have a status (published, hidden). Lynx refuses reserved names and says why.

Templates ​

php
// lynx:install made the home template and page; give it the landing page's sections
lynx()->templates->update('home', ['add' => ['summary', 'highlights', 'stats', 'featured', 'steps', 'testimonials', 'faqs']]);

lynx()->templates->add('courses', fields: [['name' => 'title', 'required' => true], 'summary'], label: 'Courses', singleton: true);
lynx()->templates->add('course', fields: [
    ['name' => 'title', 'required' => true], ['name' => 'summary', 'required' => true], 'body',
    'level', 'hours', 'price', 'curriculum', 'instructors', 'photos',
], label: 'Course');
lynx()->templates->add('session', fields: [
    ['name' => 'title', 'required' => true], ['name' => 'starts', 'required' => true], ['name' => 'location', 'required' => true],
    ['name' => 'seats', 'required' => true], 'price', 'availability',
], label: 'Session');

lynx()->templates->add('instructors', fields: [['name' => 'title', 'required' => true], 'summary'], label: 'Instructors', singleton: true);
lynx()->templates->add('instructor', fields: [['name' => 'title', 'required' => true], 'summary', 'body', 'photos'], label: 'Instructor');

lynx()->templates->add('locations', fields: [['name' => 'title', 'required' => true]], label: 'Locations', singleton: true);
lynx()->templates->add('location', fields: [['name' => 'title', 'required' => true], 'street', 'city', 'postal_code', 'photos'], label: 'Location');

lynx()->templates->add('posts', fields: [['name' => 'title', 'required' => true], 'summary'], label: 'News', singleton: true);
lynx()->templates->add('post', fields: [['name' => 'title', 'required' => true], 'summary', 'body', 'published_on', 'photos'], label: 'News post');

lynx()->templates->add('materials', fields: [['name' => 'title', 'required' => true], 'body', 'downloads'], label: 'Course materials');
lynx()->templates->add('page', fields: [['name' => 'title', 'required' => true], 'summary', 'body'], label: 'Page');
lynx()->templates->add('settings', fields: ['email', 'phone'], label: 'Settings', hasUrl: false, singleton: true);

// Where pages may go: the admin offers only these, and the API refuses the rest
$children = [
    'home' => ['courses', 'instructors', 'locations', 'posts', 'page', 'settings'],
    'courses' => ['course'], 'course' => ['session', 'materials'], 'session' => [], 'materials' => [],
    'instructors' => ['instructor'], 'instructor' => [],
    'locations' => ['location'], 'location' => [],
    'posts' => ['post'], 'post' => [], 'page' => [],
];

foreach ($children as $template => $allowed) {
    lynx()->templates->update($template, ['allow_children' => $allowed !== [], 'child_templates' => $allowed]);
}

Sessions are child pages of their course, not rows in a table of your own. That gives each session a URL to book from (chapter 7), a place in the admin's tree right under its course, and selectors like template=session, starts>=now for free. A course lists its sessions with $course->children('template=session').

Family rules keep the tree tidy: under the catalogue, staff can only add courses; under a course, only sessions. The admin offers only those templates, and the API refuses the rest. Modules' own hidden branches, such as menus, are not bound by these rules.

Demo content ​

A second seeder fills the site with six courses, twelve sessions, three instructors, two studios, news and the home page's sections, in both languages. Its data lives in database/seeders/data/atlas.php; the seeder turns it into pages:

php
<?php

declare(strict_types=1);

namespace Database\Seeders;

use Carbon\CarbonImmutable;
use Illuminate\Database\Seeder;
use LynxCms\Core\Pages\Page;
use LynxCms\Media\Actions\StoreMedia;
use RuntimeException;

/**
 * Atlas Training's demo content, in English and Portuguese: the sections of the home page,
 * the catalogue with six courses and two dated sessions each, the instructors, the studios,
 * news and the legal pages. Run it after AtlasSchemaSeeder.
 */
final class AtlasContentSeeder extends Seeder
{
    /** @var array<string, int> page ids by the demo data's keys */
    private array $ids = [];

    public function run(): void
    {
        /** @var array<string, mixed> $data */
        $data = require __DIR__.'/data/atlas.php';

        $this->sections(['courses' => ['cursos', 'Courses', 'Cursos'], 'instructors' => ['formadores', 'Instructors', 'Formadores'],
            'locations' => ['espacos', 'Studios', 'Estúdios'], 'posts' => ['noticias', 'News', 'Notícias']]);

        foreach ($data['instructors'] as $name => $instructor) {
            $this->ids[$name] = $this->page('instructor', '/instructors/', $name, $instructor)->id;
        }

        foreach ($data['locations'] as $name => $location) {
            $this->ids[$name] = $this->page('location', '/locations/', $name, $location)->id;
        }

        $week = 2;
        foreach ($data['courses'] as $name => $course) {
            $page = $this->course($name, $course);
            $this->session($page, 'lisbon', CarbonImmutable::now()->startOfWeek()->addWeeks($week++)->setTime(10, 0));
            $this->session($page, 'porto', CarbonImmutable::now()->startOfWeek()->addWeeks($week + 3)->setTime(18, 30));
            $materials = $this->page('materials', '/'.$page->path().'/', 'materials', ['pt' => 'materiais', 'title' => ['en' => 'Course materials', 'pt' => 'Materiais do curso'],
                'body' => ['en' => '<p>Your reading list and handouts. See you in class!</p>', 'pt' => '<p>A lista de leituras e os apontamentos. Até à aula!</p>']]);
            $this->download($materials, 'reading-list.pdf', "Reading list for {$name}");
        }

        $this->home($data['home']);
        $this->extras();
        $this->menus();
    }

    /** A file in the materials page's private downloads field, as an editor would upload it. */
    private function download(Page $materials, string $filename, string $contents): void
    {
        $entry = lynx()->run(StoreMedia::class, ['page' => $materials->id, 'field' => 'downloads', 'base64' => base64_encode($contents), 'filename' => $filename])->value;
        $materials->downloads = [['values' => $entry]];
        $materials->save();
    }

    /** The main and footer menus, which editors change in the admin's Menus screen. */
    private function menus(): void
    {
        lynx()->menus()->create('main', ['en' => 'Main navigation', 'pt' => 'Navegação principal']);
        foreach (['/courses/', '/instructors/', '/locations/', '/posts/'] as $path) {
            lynx()->menus()->saveItem(['menu' => 'main', 'kind' => 'page', 'page' => $path]);
        }

        lynx()->menus()->create('footer', ['en' => 'About Atlas', 'pt' => 'Sobre a Atlas']);
        foreach (['/about/', '/posts/', '/privacy/'] as $path) {
            lynx()->menus()->saveItem(['menu' => 'footer', 'kind' => 'page', 'page' => $path]);
        }
    }

    /** @param  array<string, array{0: string, 1: string, 2: string}>  $sections */
    private function sections(array $sections): void
    {
        foreach ($sections as $template => [$pt, $en, $ptTitle]) {
            $this->page($template, '/', $template, ['pt' => $pt, 'title' => ['en' => $en, 'pt' => $ptTitle]]);
        }
    }

    /** @param  array<string, mixed>  $course */
    private function course(string $name, array $course): Page
    {
        $course['instructors'] = array_map(fn (string $key): int => $this->ids[$key], $course['instructors']);
        $course['curriculum'] = array_map(fn (array $heading): array => ['values' => ['heading' => $heading]], $course['curriculum']);
        $course['body'] = ['en' => '<p>'.$course['summary']['en'].'</p>', 'pt' => '<p>'.$course['summary']['pt'].'</p>'];
        $page = $this->page('course', '/courses/', $name, $course);
        $this->ids[$name] = $page->id;

        return $page;
    }

    private function session(Page $course, string $location, CarbonImmutable $starts): void
    {
        $name = $starts->format('Y-m-d').'-'.$location;
        $this->page('session', '/'.$course->path().'/', $name, ['pt' => $name,
            'title' => [
                'en' => $course->getLanguageValue('en', 'title').', '.$starts->format('j F Y'),
                'pt' => $course->getLanguageValue('pt', 'title').', '.$starts->locale('pt')->isoFormat('D [de] MMMM [de] YYYY'),
            ],
            'starts' => $starts->format('Y-m-d H:i'), 'location' => $this->ids[$location], 'seats' => 16, 'availability' => 'scheduled',
        ]);
    }

    /** @param  array<string, mixed>  $home */
    private function home(array $home): void
    {
        $page = lynx()->pages->get('/') ?? throw new RuntimeException('Run php artisan lynx:install first.');
        $item = fn (array $values): array => ['values' => $values];

        $this->fill($page, [
            'title' => $home['title'], 'summary' => $home['summary'],
            'highlights' => array_map(fn (array $h): array => $item(['icon' => $h[0], 'heading' => $h[1], 'text' => $h[2]]), $home['highlights']),
            'stats' => array_map(fn (array $s): array => $item(['figure' => $s[0], 'heading' => $s[1]]), $home['stats']),
            'featured' => array_map(fn (string $key): int => $this->ids[$key], $home['featured']),
            'steps' => array_map(fn (array $s): array => $item(['heading' => $s[0], 'text' => $s[1]]), $home['steps']),
            'testimonials' => array_map(fn (array $t): array => $item(['text' => $t[0], 'person' => $t[1], 'rating' => $t[2]]), $home['testimonials']),
            'faqs' => array_map(fn (array $f): array => $item(['heading' => $f[0], 'text' => $f[1]]), $home['faqs']),
        ])->save();
    }

    /** News, the about and privacy pages, and the settings page. */
    private function extras(): void
    {
        $this->page('post', '/posts/', 'new-porto-studio', ['pt' => 'novo-estudio-porto', 'published_on' => CarbonImmutable::now()->subDays(10)->format('Y-m-d'),
            'title' => ['en' => 'Our new Porto studio', 'pt' => 'O nosso novo estúdio no Porto'],
            'summary' => ['en' => 'Classes start next month in Cedofeita.', 'pt' => 'As aulas começam no próximo mês em Cedofeita.']]);
        $this->page('page', '/', 'about', ['pt' => 'sobre', 'title' => ['en' => 'About us', 'pt' => 'Sobre nós'],
            'body' => ['en' => '<p>Atlas Training has taught wellness professionals since 2011.</p>', 'pt' => '<p>A Atlas Training forma profissionais de bem-estar desde 2011.</p>']]);
        $this->page('page', '/', 'privacy', ['pt' => 'privacidade', 'title' => ['en' => 'Privacy', 'pt' => 'Privacidade']]);
        $this->page('settings', '/', 'settings', ['email' => 'hello@atlas-training.example', 'phone' => '+351 210 000 000']);
    }

    /**
     * A page with its URL names and values. Texts given as ['en' => …, 'pt' => …] go to each
     * language; anything else is shared.
     *
     * @param  array<string, mixed>  $values  the page's values, plus "pt" for its Portuguese URL name
     */
    private function page(string $template, string $parent, string $name, array $values): Page
    {
        $page = lynx()->pages->new($template, $parent);
        $page->name = $name;

        if (isset($values['pt'])) {
            $page->setLanguageName('pt', $values['pt']);
        }

        unset($values['pt']);
        $this->fill($page, $values)->save();

        return $page;
    }

    /** @param  array<string, mixed>  $values */
    private function fill(Page $page, array $values): Page
    {
        foreach ($values as $field => $value) {
            if (is_array($value) && array_keys($value) === ['en', 'pt']) {
                $page->setLanguageValue('en', $field, $value['en'])->setLanguageValue('pt', $field, $value['pt']);
            } else {
                $page->{$field} = $value;
            }
        }

        return $page;
    }
}

Run both, and keep the schema in Git:

bash
php artisan db:seed --class=AtlasSchemaSeeder
php artisan db:seed --class=AtlasContentSeeder
php artisan lynx:schema:export

lynx:schema:export writes one YAML file per language, field, template and role into lynx/schema. Commit them. A teammate's machine or a staging server applies them with lynx:schema:apply, and a change made in the admin shows up as a diff in a pull request. Schema and Content Packages covers how environments stay in step.

What you get ​

  • The client: forms in the admin that match their courses: a level to pick, a price, a curriculum to sort, sessions under each course. Portuguese tabs on every text field.
  • You: a model in code and in Git, typed values in templates ($session->starts is a date, $course->level a choice), and selectors that query it all.

Released under the MIT License.