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.
Content models
Section titled “Content models”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 name — homepage, 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:

The is_published publish gate
Section titled “The is_published publish gate”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.pageNamebinds directly byname, so an edit tohomepage/aboutgoes live to the apps on save — there is no draft state.Newshas nois_publishedcolumn; it is gated instead by destination assignment / permissions (broker news routes carryresource-visibility:newsSlug, andGetNewsWithFilters::applyPermissions()restricts to the caller’s allowed destinations). Itspublished_atis a display date, not a gate.Testimonialvisibility follows the assigned-destination scope (HasAssignedScope+filterByAssigned), keyed off the parentDestination’s publish state for editors.
Localization
Section titled “Localization”Translatable content is multilingual across four locales — en, 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:
News→title,short_description,content(single-value:slug,is_featured,is_event,published_at)Testimonial→name,position,reviewSection→name
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.
How the apps consume it
Section titled “How the apps consume it”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}):
getHomeMetaData→GET {p}/pages/homepagegetAboutUsMetaData→GET {p}/pages/aboutgetPrivacyPolicy→GET {p}/pages/privacy-policygetTermsAndConditions→GET {p}/pages/terms-and-conditionsgetFAQs→GET {p}/pages/faqsgetCustomPage→GET {p}/custom-pages(drives/highlights/:pageSlug)getNews→GET {p}/news;getNewsDetails→GET {p}/news/{newsSlug}getActivePromotion→GET {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.
Gotchas
Section titled “Gotchas”- 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_publishedonly gates catalog +CustomPage.Page(static pages) has no draft/publish state — editinghomepage/aboutin Voyager is immediately live.News/Testimonialare gated by destination assignment & permissions, not byis_published.- The
localeheader is mandatory for non-English content. A client that omits thelocaleheader getsen(the middleware default). Empty locale fields fall back toen, 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}wherepageName∈homepage | about | privacy-policy | terms-and-conditions | faqs. An unknown name is a 404 (firstOrFail()). News.is_featuredis singleton-enforced. Setting a news item featured unsets the previously featured one via the model’sbooted()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
$translatablearray have per-locale tabs (e.g.News.title);slug, flags, and dates are shared across all locales. CustomPageeager-loadssectionsandimages(protected $with), so its list endpoint returns full page trees — no separate section fetch is needed by the apps.
