Skip to content

CMS & content

Every piece of editorial content the public apps display — home/about/privacy pages, News, highlights, Testimonials, FAQs — is authored in the Laravel Voyager admin and read back by the apps over the backend REST API. There is no separate CMS process: content lives in the same MySQL database as the catalog, is edited through Voyager BREAD screens, and is exposed by ordinary Laravel controllers.

This page is the content-integration reference: the models, the endpoints, the publish gate, and the localization mechanism. For the admin UI itself (what the BREAD screens look like, how staff publish), see The Voyager admin (CMS). For the underlying tables, see the data model.

Editorial content is a set of Eloquent models under backend/app/Models/. Each is edited as a Voyager BREAD and served to the apps through a dedicated controller/endpoint. The apps never talk to a CMS — they call these Laravel routes.

Model (app/Models/…) What it is Public endpoint(s) Serialized by
Page Singleton “static” pages keyed by namehomepage, about, privacy-policy, terms-and-conditions, faqs. Carries metadata, FAQs, videos, and ordered sections. GET {p}/pages/{pageName} (shopper) PageResource
CustomPage + CustomPageSection Editor-authored marketing pages (the shopper’s /highlights/:slug). A CustomPage owns ordered CustomPageSection rows (title, description, image, video). GET {p}/custom-pages (shopper) CustomPageResource / CustomPageSectionResource
News News & events articles. Flags is_event, is_featured; published_at display date; linked to Destinations. GET {p}/news, GET {p}/news/{newsSlug} (shopper + broker) NewsSummaryResource (list) / NewsResource (detail)
Testimonial Customer reviews attached to a Destination (name, position, review, reviewer image). GET {p}/destinations/{destinationSlug}/testimonials (shopper, broker, sales-man) TestimonialResource
Section Reusable visible/hidden content blocks polymorphic on Country/Destination/Unit/Launch. Embedded in destination/unit payloads (embedded)
FrequentlyAskedQuestion Q&A entries polymorphic on a page/destination/launch. GET {p}/destinations/{destinationSlug}/faqs; also embedded in PageResource.faqs FrequentlyAskedQuestionResource
ConstructionUpdate, EducationalHub Destination-scoped rich content. GET {p}/destinations/{destinationSlug}/construction-updates, …/educational-hubs (dedicated resources)
Promotion Active site-wide promotion banner. GET {p}/promotions (shopper) PromotionResource

{p} is the app’s API base (for the shopper NX_SHOPPER_API_ENDPOINT, for the broker NX__BROKER_API_ENDPOINT, etc.). Route params like {pageName} and {newsSlug} are Laravel route-model bindings — for example pageName resolves via Page::where('name', $name)->firstOrFail() (app/Providers/RouteServiceProvider.php), so a page is fetched by its name, not a numeric id.

Every response is wrapped in the platform’s standard envelope by the FormatApiResponse middleware:

{ "data": <resource or collection>, "message": "success" }

The Custom Pages BREAD is where editors author those /highlights/:slug marketing pages:

Voyager Custom Pages BREAD — editor-authored marketing pages by name and slug (the shopper’s /highlights/).

The platform’s core visibility rule is a boolean is_published flag. A row must be published before it reaches the public API. The models that carry it are the catalog spine plus custom pages:

  • Catalog: Country, Destination, Project, Unit, UnitType, Launch
  • Content: CustomPage

CustomPage exposes a scopePublished() (where('is_published', true)), and the shopper endpoint only ever returns published pages — GetPublishedCustomPages calls CustomPage::published() before serializing (app/Actions/CustomPage/GetPublishedCustomPages.php). For the catalog, the gate is enforced at the route binding: shopper unit routes bind through Unit::…->published() and broker/sales-man routes through publishedForBroker() / publishedForSalesMan() (RouteServiceProvider.php), so an unpublished unit firstOrFail()s into a 404 rather than leaking.

Not every content model uses is_published, and this trips people up:

  • Page (the static/singleton pages) has no publish gate. pageName binds directly by name, so an edit to homepage/about goes live to the apps on save — there is no draft state.
  • News has no is_published column; it is gated instead by destination assignment / permissions (broker news routes carry resource-visibility:newsSlug, and GetNewsWithFilters::applyPermissions() restricts to the caller’s allowed destinations). Its published_at is a display date, not a gate.
  • Testimonial visibility follows the assigned-destination scope (HasAssignedScope + filterByAssigned), keyed off the parent Destination’s publish state for editors.

Translatable content is multilingual across four localesen, de, ar, ru — configured under multilingual in backend/config/voyager.php (default en), with the app-wide default and fallback both en (backend/config/app.php).

Translation is Voyager’s TCG\Voyager\Traits\Translatable trait. A model declares which fields are translated in a $translatable array; the rest are single-value columns. For example:

  • Newstitle, short_description, content (single-value: slug, is_featured, is_event, published_at)
  • Testimonialname, position, review
  • Sectionname

Translated values are stored in Voyager’s polymorphic translations table (eager-loaded via protected $with = ['translations']), and resources emit the active locale with getTranslatedAttribute('title') — see NewsSummaryResource / NewsResource.

How a locale is chosen per request: the apps send a locale request header. Each app’s network layer sets locale: i18next.language ?? 'en' on every request (network.ts in the shopper, broker, sales-man, and analytics apps). On the backend, the SetLocale middleware (app/Http/Middleware/SetLocale.php, registered in the api group) reads that header ($request->header('locale', 'en')) and calls App::setLocale(...), which is what getTranslatedAttribute() reads when the resource is serialized. There is no locale in the URL or a query param — it is header-driven.

Because getTranslatedAttribute() uses fallback ($fallback = true), a field that is empty in the requested locale falls back to en rather than returning null. So a half-translated article renders in English for the missing fields, not blank.

The apps are pure API consumers — they hold no CMS SDK, no content database, and no build-time content fetch. They call the endpoints above and render the JSON.

Shopper app (orascom-shopper-app, base {p}):

  • getHomeMetaDataGET {p}/pages/homepage
  • getAboutUsMetaDataGET {p}/pages/about
  • getPrivacyPolicyGET {p}/pages/privacy-policy
  • getTermsAndConditionsGET {p}/pages/terms-and-conditions
  • getFAQsGET {p}/pages/faqs
  • getCustomPageGET {p}/custom-pages (drives /highlights/:pageSlug)
  • getNewsGET {p}/news; getNewsDetailsGET {p}/news/{newsSlug}
  • getActivePromotionGET {p}/promotions
  • Testimonials / FAQs / construction updates / educational hubs → GET {p}/destinations/{destinationSlug}/…

Broker app (orascom-broker-app): GET {b}/news, GET {b}/news/{newsSlug} (with resource-visibility), and the per-destination testimonials, faqs, construction-updates, educational-hubs.

Sales-man app (orascom-sales-man-app): per-destination testimonials (and the shared destination-scoped content).

All of these ride the same Authorization: Bearer <token> + locale headers as the rest of the API, and all return the { data, message } envelope.

  • Do not go looking for a Payload/headless CMS. There isn’t one. Content = Voyager BREAD → Eloquent → Laravel resources. grep -ri payload backend/ returns nothing meaningful.
  • is_published only gates catalog + CustomPage. Page (static pages) has no draft/publish state — editing homepage/about in Voyager is immediately live. News/Testimonial are gated by destination assignment & permissions, not by is_published.
  • The locale header is mandatory for non-English content. A client that omits the locale header gets en (the middleware default). Empty locale fields fall back to en, so wrong-language or half-translated content shows up as English, not as an error — check the locale tab in Voyager, not the app.
  • Pages resolve by name, not id. GET {p}/pages/{pageName} where pageNamehomepage | about | privacy-policy | terms-and-conditions | faqs. An unknown name is a 404 (firstOrFail()).
  • News.is_featured is singleton-enforced. Setting a news item featured unsets the previously featured one via the model’s booted() hook (handleIsFeatured) — there is at most one featured article platform-wide.
  • Translatable vs single-value fields matter when editing. Only fields in a model’s $translatable array have per-locale tabs (e.g. News.title); slug, flags, and dates are shared across all locales.
  • CustomPage eager-loads sections and images (protected $with), so its list endpoint returns full page trees — no separate section fetch is needed by the apps.