Skip to content

CMS Content API

Content management system for organizing travel destinations into regions and countries, plus the About Us page, managed via Filament admin and served to the frontend via API.

  • Display destination regions on the home page carousel
  • Show region detail pages with content sections (quote, experiences, CTA)
  • Show countries within a region with stats and image galleries
  • Display linked products on country/destination pages
  • Serve translated global labels for region page UI (hero buttons, newsletter)
  • Serve the About Us page with translated header, caption, and trailers
  • Manage destination content and ordering from the admin panel

Regions group countries. Countries link to products via a pivot table. Both have translations. Region labels provide global per-locale UI strings.

CmsRegion (slug, image, full_width_image, sort_order, status)
├── CmsRegionTranslation (locale, name, description, quote, experiences,
│ cta_heading, cta_subheading, cta_button_text)
└── CmsCountry (slug, image, images, sort_order, status)
├── CmsCountryTranslation (locale, name, description, tagline,
│ population, area, currency, best_time, languages,
│ population_label, area_label, currency_label, best_time_label, languages_label,
│ experiences JSON with product_by_market_id references)
└── ProductByMarket (via cms_country_product_by_market pivot, with sort_order)
CmsRegionLabelTranslation (locale, hero_button_primary, hero_button_secondary,
newsletter_heading, newsletter_name_placeholder, newsletter_email_placeholder,
newsletter_button_text)
CmsAboutUsPage (video_url, status) — singleton
└── CmsAboutUsPageTranslation (locale, title, lead_caption, supporting_caption,
description, trailers JSON)

Key fields:

  • experiences — JSON object with intro (eyebrow, title, description, media) and phases array (title, caption, experience_name, media, related_product_ids referencing ProductByMarket). The API resolves product IDs into trip data (title, itinerary, duration+price, href) filtered by market.

  • images — JSON array of gallery image paths on CmsCountry (stored on public disk, converted to full URLs)

  • Stat labels (population_label, etc.) are per-country per-locale, not global

  • trailers — JSON array on translations: each entry has poster_image (storage path or URL), poster_alt, href, and optional target (_self/_blank). Entries missing any required field are skipped in the API response.

Source: backend/app/Models/CmsRegion.php, backend/app/Models/CmsCountry.php, backend/app/Models/CmsRegionLabelTranslation.php, backend/app/Models/CmsAboutUsPage.php

All endpoints live inside the /{market}/{lang}/cms/ route group and use the market middleware for locale resolution.

Source: backend/routes/api.php:66

All active regions with their active countries, translated to the resolved locale.

Response shape:

{
"data": [
{
"slug": "asia",
"name": "Asia",
"description": "Discover the wonders of Asia",
"image": "https://cdn.example.com/regions/asia.jpg",
"fullWidthImage": "https://cdn.example.com/regions/asia-wide.jpg",
"quote": "A JOURNEY THROUGH TEMPLES AND RICE FIELDS",
"experiences": {
"intro": {
"eyebrow": "Curated memories",
"title": "Discover unique experiences",
"description": "...",
"media": { "src": "https://cdn.example.com/experiences/bg.jpg", "alt": "" },
"previewMedia": { "src": "https://cdn.example.com/experiences/phase1.jpg", "alt": "" }
},
"phases": [
{
"id": "0",
"title": "A quote about this phase",
"caption": "Hotel Riamal - Córdoba, Argentina",
"experienceName": "Hotel Riamal",
"modalTitle": "Viajes para vivir Hotel Riamal",
"triggerAriaLabel": "Abrir viajes para vivir Hotel Riamal",
"media": { "src": "https://cdn.example.com/experiences/phase1.jpg", "alt": "" },
"relatedTrips": [
{
"id": "10",
"href": "/es/circuito/tour-sri-lanka",
"country": "Sri Lanka",
"title": "Tour de Sri Lanka",
"itinerary": "Colombo · Kandy · Ella",
"duration": "10 nights · from 1.249 €"
}
]
}
]
},
"ctaHeading": "Write your next chapter",
"ctaSubheading": "Schedule a call and we'll design your trip.",
"ctaButtonText": "talk to us",
"countries": [
{
"slug": "sri-lanka",
"name": "Sri Lanka",
"description": "The pearl of the Indian Ocean",
"image": "https://cdn.example.com/countries/sri-lanka.jpg",
"images": ["https://cdn.example.com/gallery/1.jpg"],
"population": "22 million",
"area": "65,610 km²",
"currency": "Rupee (LKR)",
"bestTime": "December - March",
"languages": "Sinhala, Tamil",
"populationLabel": "Population",
"areaLabel": "Area",
"currencyLabel": "Currency",
"bestTimeLabel": "Best time to visit",
"languagesLabel": "Languages"
}
]
}
]
}

Source: backend/app/Http/Controllers/Api/CmsController.php:22

GET /api/{market}/{lang}/cms/regions/{slug}

Section titled “GET /api/{market}/{lang}/cms/regions/{slug}”

Single region by slug with its active countries. Returns 404 if the slug does not match an active region.

Response: Same shape as a single item from the regions list above.

Source: backend/app/Http/Controllers/Api/CmsController.php:42

GET /api/{market}/{lang}/cms/region-labels

Section titled “GET /api/{market}/{lang}/cms/region-labels”

Global per-locale UI labels for region pages (hero buttons, newsletter section). Returns hardcoded defaults when no translation exists for the resolved locale.

Response shape:

{
"data": {
"hero_button_primary": "VER PAÍSES",
"hero_button_secondary": "A MEDIDA",
"newsletter_heading": "Subscribe to our newsletter...",
"newsletter_name_placeholder": "Name",
"newsletter_email_placeholder": "Email",
"newsletter_button_text": "enviar"
}
}

Fallback behavior: Each field falls back independently to its default value when the locale translation is missing or the field is null. See LABEL_DEFAULTS in the controller.

Source: backend/app/Http/Controllers/Api/CmsController.php:70

GET /api/{market}/{lang}/cms/countries/{slug}

Section titled “GET /api/{market}/{lang}/cms/countries/{slug}”

Single country by slug with its parent region and editorial experiences. Each experience references a product_by_market_id directly; the matching ProductByMarket is loaded per requested market at read time.

Response shape:

{
"data": {
"slug": "sri-lanka",
"name": "Sri Lanka",
"description": "The pearl of the Indian Ocean",
"tagline": "THE PEARL OF THE INDIAN OCEAN",
"image": "https://cdn.example.com/countries/sri-lanka.jpg",
"images": [
"https://cdn.example.com/gallery/1.jpg",
"https://cdn.example.com/gallery/2.jpg"
],
"population": "22 million",
"area": "65,610 km²",
"currency": "Rupee (LKR)",
"bestTime": "December - March",
"languages": "Sinhala, Tamil",
"populationLabel": "Population",
"areaLabel": "Area",
"currencyLabel": "Currency",
"bestTimeLabel": "Best time to visit",
"languagesLabel": "Languages",
"experiences": [
{
"image": "https://cdn.example.com/experiences/tea.jpg",
"title": "Walk through tea fields at dawn",
"tag": "Nature & landscapes",
"description": "An editorial description of this experience.",
"product": {
"id": 10,
"title": "Tour de Sri Lanka",
"slug": "tour-sri-lanka",
"path": "/es/circuito/tour-sri-lanka",
"durationDays": 10,
"priceFrom": "1249",
"currency": "EUR",
"image": "https://cdn.example.com/images/hero.jpg",
"itinerary": "Colombo · Kandy · Ella · Galle"
}
}
],
"region": {
"slug": "asia",
"name": "Asia"
},
"relatedCountries": [
{
"slug": "india",
"name": "India",
"tagline": "A SUBCONTINENT OF WONDERS",
"image": "https://cdn.example.com/countries/india.jpg",
"region": "Asia"
}
]
}
}

Key fields:

  • experiences — editorial content per country, stored as JSON on translations. Each entry has editorial fields (image, title, tag, description) plus a product_by_market_id reference. The API resolves this into a product object (null if no matching active product for the current market)
  • product.itinerary — cities from the product template joined with ·
  • images — gallery image URLs (storage paths converted to full URLs)
  • Stat labels (populationLabel, etc.) are per-country translated strings

Source: backend/app/Http/Controllers/Api/CmsController.php:87

About Us page content (singleton) translated to the resolved locale. Returns null for data when no active page exists.

Response shape:

{
"data": {
"headerSection": {
"title": "Viajar es salir de nuestra vida...",
"leadCaption": "Ser un explorador en Kenia...",
"supportingCaption": "Hay quien dice que viajar...",
"media": {
"kind": "youtube",
"src": "https://www.youtube.com/watch?v=abc123",
"title": "VOLARE About Us"
}
},
"captionSection": {
"text": "En Volāre creemos que el viaje..."
},
"trailersSection": {
"items": [
{
"id": "trailer-0",
"href": "https://www.youtube.com/watch?v=trail1",
"posterSrc": "https://cdn.example.com/cms-about-us/trailers/poster.jpg",
"posterAlt": "Patagonia trailer",
"ariaLabel": "Patagonia trailer",
"target": "_blank"
}
]
}
}
}

Key fields:

  • media — null when video_url is not set on the page record
  • trailersSection.items — trailer entries with missing href, poster_image, or poster_alt are silently skipped
  • Storage paths in poster_image are converted to full URLs; existing URLs are passed through

Source: backend/app/Http/Controllers/Api/CmsController.php:131, backend/app/Http/Resources/CmsAboutUsPageResource.php

Four Filament admin interfaces under the CMS navigation group.

CRUD for regions with vertical tabs (General + Translations). General tab has slug, hero image, full-width image uploads (public disk), and active toggle. Translations tab uses nested tabs per translation: Content (name, description, quote), Call to Action, and Experiences. The Experiences tab has an Intro section (eyebrow, title, description, background image) and a Phases repeater where each phase has experience name, caption, title, phase image, and a searchable multi-select for related ProductByMarket products.

Source: backend/app/Filament/Resources/CmsRegions/Schemas/CmsRegionForm.php

CRUD for countries with vertical tabs (General + Translations). General tab has region select, slug, hero image, gallery images (multiple file upload, public disk), sort order, and active toggle. Translations tab has Content (name, description, tagline, country stats) and Experiences tabs. The Experiences tab has a repeater for editorial experiences, each with image, title, tag, description, and a required product select filtered to only show ProductByMarket records attached via the Products by Market relation manager.

Source: backend/app/Filament/Resources/CmsCountries/Schemas/CmsCountryForm.php

Singleton CMS page for the About Us content. Only one record can exist (create button hidden when a record exists). Vertical tabs: General (video URL, active toggle) and Translations. Each translation has nested tabs: Header (locale, title, lead caption, supporting caption), Content (description), and Trailers (repeater with poster image upload, alt text, link URL, link target).

Source: backend/app/Filament/Resources/CmsAboutUsPages/Schemas/CmsAboutUsPageForm.php

Region Labels (/admin/cms-region-labels-settings)

Section titled “Region Labels (/admin/cms-region-labels-settings)”

Settings page for managing global per-locale region page labels. Uses vertical tabs (Hero Buttons, Newsletter) with repeaters for adding translations per locale. Each locale row is upserted on save; removed locales are deleted.

Source: backend/app/Filament/Pages/Settings/CmsRegionLabelsSettings.php

PageRouteData Source
Home/[market]/homeGET cms/home — regions carousel
Region/[market]/regions/[slug]GET cms/regions/{slug} + GET cms/region-labels (parallel)
Country/[market]/destinations/[country]GET cms/countries/{slug} — country + products
About Us/[market]/about-usGET cms/about-us — header, caption, trailers (with hardcoded fallback)

The region page fetches both the region data and region labels in parallel using Promise.allSettled. If region labels fail, frontend defaults are used. The experiences response is mapped to HorizontalScrollSection props (intro + phases with related trips). Country stat labels (populationLabel, etc.) come from each country’s own translation, not from global labels.

The About Us page fetches CMS data and merges each section individually with hardcoded fallback content. If the API fails or returns null, the fallback is used. The PublicLayout navbar dynamically resolves the About Us link to /{market}/about-us.

Source: frontend/src/pages/[market]/regions/[slug].astro, frontend/src/pages/[market]/about-us.astro

  • Only active (status = true) regions and countries appear in API responses
  • Country products are filtered by the request market and must be active
  • Sort order controls display position in both admin and API
  • Translations are matched by the locale resolved from {market}/{lang}
  • A country always belongs to exactly one region
  • Region label fields fall back individually to hardcoded defaults when missing
  • Image storage paths are converted to full URLs via Storage::disk('public')->url()

CmsSeeder seeds 3 regions (Andes del Sur, Centroamerica, Sudeste Asiatico) with 2 countries each, all with es_ES translations including region content fields (quote, experiences, CTA), country stats with labels, gallery images, and region labels. Run via:

Terminal window
./vendor/bin/sail artisan db:seed --class=CmsSeeder

Source: backend/database/seeders/CmsSeeder.php

44 tests in backend/tests/Feature/Api/CmsControllerTest.php organized across six describe blocks:

  • GET /regions (4 tests) — active filtering, translation, country inclusion, empty state
  • GET /regions/{slug} (9 tests) — single region, 404s, content fields, null optional fields, country stats and images
  • GET /countries/{slug} (11 tests) — country with experiences, product enrichment via product_by_market_id, 404s, market filtering, stats fields, images gallery
  • GET /region-labels (3 tests) — default labels, translated labels, locale fallback
  • GET /home (9 tests) — home page aggregation
  • GET /about-us (8 tests) — translated content, null when missing/inactive, video media, trailers with storage URLs, skipping incomplete trailers, locale matching
  • Multi-Market API — parent route group and market middleware
  • Products by Market — ProductByMarket model linked via pivot
  • Source: backend/app/Http/Resources/CmsRegionResource.php
  • Source: backend/app/Http/Resources/CmsCountryResource.php
  • Source: backend/app/Http/Resources/CmsAboutUsPageResource.php