Portal Design
A project's portal design as a connector's creator surface edits it — the draft beside the published design, every choice with its label and options, a signed preview address, and the saves, publish, discard and reset the dashboard's designer makes.
Subscriby\Connector\Core\PortalDesign. Every project has a hosted portal page, and its look is one
design document, published beside a draft: the creator changes the draft, previews it on the real
page and publishes when it is right. This port is that designer for a platform that has a
conversation with creators. Every call takes the CreatorRef of the account that is talking and
the ProjectRef of the project, and runs as that creator: the owner, or a teammate under the same
project permissions the dashboard's designer checks. The details answer every choice twice over,
the stored value a save sends back and the label the core phrases for it, with the options each
control offers, so a surface prints words and never maps a value to a name of its own.
The images stay on the dashboard
The banner image and the project photo are project attributes, not design keys: they are uploaded in the dashboard's designer and show on the portal at once. A connector's surface says so and links to the dashboard until it can take an upload.
designOf()
$design = $this->designs->designOf($creator, $project);
$design->projectName;
$design->hasDraft; // whether a draft waits to be published or discarded
$design->draftSavedAt; // when it was last saved, or null
$design->previewUrl; // a signed address that shows the draft on the real page for ten minutes, or null without a portal page
$design->portalUrl; // the portal page, or null while the project has no handle
$design->preset->label; // "Classic", with ->value "classic"
foreach ($design->presets as $option) { $option->value; $option->label; }
$design->colorMode->label; // "Light", "Dark" or "Follow the visitor"
$design->colorModes; // the three, as options
$design->accent; // "#a3e635", every colour a six-digit lowercase hex code
$design->accentSecondary; // the creator's second accent, or null for the preset's own
$design->accentSecondaryInUse; // what the portal paints with either way
$design->background; $design->grid; $design->surface; $design->text; $design->heading; $design->muted;
$design->tagline; // one line under the project's name, or null
$design->taglineMax; // 80
foreach ($design->socialLinks as $link) { $link->network; $link->networkLabel; $link->url; $link->label; $link->title; }
$design->socialLinksMax; // 12
$design->networks; // every network a link may point at, as options
$design->customNetwork; // the value of the one network whose link the creator names
$design->sections; // the sections a reset may name, as options
$design->fontDisplay->label; // "Asap Condensed", the face of the headings and figures
$design->fontBody->label; // "Inter", the face of everything else
$design->fonts; // the twelve faces as options, the bundled pair first
$design->headingCase->label; // "Uppercase" or "As written"
$design->headingCases; // the two, as options
$design->typeScale->label; // "Compact", "Regular" or "Large"
$design->typeScales; // the three, as options
$design->radius->label; // "Sharp", "Soft" or "Round"
$design->radii; // the three, as options
$design->buttonStyle->label; // "Pill", "Rounded" or "Square"
$design->buttonStyles; // the three, as options
$design->buttonFill->label; // "Dark", "Accent" or "Outline"
$design->buttonFills; // the three, as options
$design->buttonCase->label; // "Uppercase" or "As written"; its options are headingCases
$design->shadow->label; // "None", "Soft" or "Strong"
$design->shadows; // the three, as options
$design->cardBorder; // whether the two surface cards draw a hairline border
$design->arrangement->label; // "Left", "Right", "Stacked" or "Hero"
$design->arrangements; // the four, as options
$design->contentWidth->label; // "Narrow", "Regular" or "Wide"
$design->contentWidths; // the three, as options
$design->stickySidebar; // whether the sidebar card stays in view while the page scrolls
$design->planGrid->label; // "List", "Two columns" or "Three columns"
$design->planGrids; // the three, as options
$design->planDensity->label; // "Compact" or "Roomy"
$design->planDensities; // the two, as options
$design->plansHeading; // whether the plan list opens with its heading and subtitle
$design->metricsSubscribers; // whether the subscriber count shows, while the project shows metrics at all
$design->metricsFollowers; // whether the follower count shows
$design->metricsThreshold; // the count a figure must reach before it shows; 0 shows every figure
$design->plansTitle; // the creator's heading over the plan list, or null for the built-in wording
$design->plansSubtitle; // ... and so on for labelLogin, labelShare, labelSubscribe, checkoutTitle,
$design->successMessage; // checkoutSubtitle, checkoutNote, successTitle and successMessage
$design->copyMax; // ['plans_title' => 60, 'label_login' => 24, ...], the ceiling of each text
$design->perksTitle; // the heading over the perks, or null for the built-in wording
$design->perks; // ['Weekly research notes', ...], the perks in order
$design->faqTitle; // the heading over the questions, or null for the built-in wording
$design->faq; // PortalFaqItemDetails[]: ->question and ->answer
$design->aboutName; // the creator's name on the about card, or null
$design->aboutBio; // a few lines about the creator, or null
$design->blockLimits; // ['perks' => 12, 'perk' => 120, 'faq' => 12, 'faq_question' => 160, 'faq_answer' => 800, ...]
$design->customButtonLabel; // the second sidebar button's label, or null when there is no button
$design->customButtonUrl; // its address; the pair is always both or neither
$design->footerRefundUrl; // the footer links, or null for none: refund policy, contact (a page or mailto:), imprint
$design->footerLine; // a line of the creator's under the footer links, or null
$design->footerHelp; // whether the footer carries the Help link
$design->customButtonLabelMax; // 24
$design->footerLineMax; // 120
$design->announcementText; // the strip across the top of the page, or null for none
$design->announcementUrl; // where it leads, or null
$design->announcementStartsAt; // "2026-11-01T03:30:00Z", ISO 8601 in UTC, or null for at once
$design->announcementEndsAt; // the same, or null for never
$design->announcementTextMax; // 160
$design->videoUrl; // "https://www.youtube.com/watch?v=…" or "https://vimeo.com/…", the canonical address, or null
$design->videoProvider; // "YouTube" | "Vimeo" | null
$design->galleryTitle; // the heading over the gallery, or null for the built-in wording
$design->gallery; // string[]: the pictures as stored, in order
$design->testimonialsTitle; // the heading over the quotes, or null for the built-in wording
$design->testimonials; // PortalTestimonialDetails[]: ->quote, ->name, ->subtitle (nullable, a title or a city), ->avatarUrl (nullable)
$design->mediaLimits; // ['gallery' => 8, 'testimonials' => 12, 'testimonial_quote' => 300, 'testimonial_name' => 60, 'testimonial_subtitle' => 60, 'title' => 60]
$design->sidebarItems; // PortalPlacementDetails[] in order: ->key ("tagline", "metrics", "buttons", "description", "social_links", "footer"), ->label, ->shown
$design->contentBlocks; // PortalPlacementDetails[] in order: ->key ("perks", "about", "faq", "video", "gallery", "testimonials"), ->label, ->shown
$design->localeSwitcher; // whether the list of languages shows under the sidebar card
$design->featuredPlanId; // the plan with the Most popular badge, or null
$design->featuredPlanName; // its name, or null
$design->planOptions; // PortalDesignOption[]: every plan of the project, ->value an id the draft may feature, ->label its name
$design->couponBox; // "always" | "link" (only once a coupon arrived with the visit)
$design->couponBoxes; // PortalDesignOption[] for the two settings
$design->shareButton; // whether the header shows the Share button
$design->shareNetworks; // string[]: the networks the share sheet offers, e.g. "x", "telegram", "whatsapp"
$design->shareNetworkOptions; // PortalDesignOption[]: every network the sheet can draw
$design->loginButton; // whether a visitor sees the Login button
$design->socialProof; // whether the plan list says how many joined this week
$design->seatsBadges; // whether a series card shows the seats left
$design->seoIndexable; // whether search engines may list the portal, on top of the project's own readiness
$design->seoSocialCard; // whether a shared link sends the generated card
$design->pixelMeta; // the Meta Pixel dataset id, or null
$design->pixelGa4; // the Google Analytics 4 measurement id ("G-…"), or null
$design->pixelTiktok; // the TikTok Pixel id, or null
$design->pixelGoogleAds; // the Google Ads conversion id ("AW-…"), or null
$design->customCss; // the creator's own stylesheet, or null
$design->customCssMax; // 16384
$design->seoTitleSuffix; // the words after the project's name in the page title, or null for "Official Subscriptions"
$design->seoDescription; // the description search engines and shared links show, or null for one cut from the project's text
$design->socialCardUrl; // an uploaded card as stored, or null for the card the portal draws in its colours
$design->seoTitleSuffixMax; // 60
$design->seoDescriptionMax; // 160
$design->defaultLocale; // "es" or null for the platform's default
$design->locales; // string[]: the languages the switcher offers, e.g. ["en", "es"]
$design->localeOptions; // PortalDesignOption[]: every language, ->value a code, ->label its own namePortalDesignDetails is the draft when one waits, else the published design, which is what a
creator edits either way: the first change to a project with no draft starts one from the
published design, and nothing a visitor sees moves until publish().
saveDraft()
use Subscriby\Connector\Data\PortalDesignDraft;
use Subscriby\Connector\Data\PortalPlacementDraft;
use Subscriby\Connector\Data\PortalSocialLinkDraft;
use Subscriby\Connector\Data\PortalTestimonialDraft;
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(tagline: 'Weekly notes, every Monday'));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(preset: 'midnight'));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(colorMode: 'system'));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(accent: '#f43f5e', grid: false));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(text: '#27272a', surface: '#ffffff')); // the body text, the headings and the captions must read on the surface and the background: a pair under a 3:1 contrast is refused as invalid_design
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(accentSecondary: PortalDesignDraft::NONE));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(fontDisplay: 'playfair-display', fontBody: 'manrope'));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(headingCase: 'as_written', typeScale: 'large'));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(radius: 'sharp', buttonStyle: 'square', buttonFill: 'outline', shadow: 'none', cardBorder: true));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(arrangement: 'stacked', contentWidth: 'wide', planGrid: 'two', planDensity: 'compact', plansHeading: false, metricsThreshold: 50));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(plansTitle: 'Pick your pass', labelSubscribe: 'Join', successMessage: "Grab a seat.\n\nThe doors open at nine."));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(plansTitle: PortalDesignDraft::NONE)); // back to the built-in wording
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(perks: ['Weekly research notes', 'The members lounge'], aboutName: 'Ada'));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(faq: [new PortalFaqItemDraft('When do the notes arrive?', 'Every Monday, before nine.')])); // the whole list each time
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(customButtonLabel: 'Our store', customButtonUrl: 'https://store.example.com', footerContactUrl: 'mailto:hello@example.com', footerHelp: false));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(announcementText: 'Doors open Sunday at nine', announcementStartsAt: '2026-11-01T09:00:00+05:30', announcementEndsAt: '2026-11-02T09:00:00+05:30')); // any zone in, UTC out
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(videoUrl: 'https://youtu.be/dQw4w9WgXcQ', testimonials: [new PortalTestimonialDraft('Worth every cent.', 'Grace Hopper', 'Lisbon')])); // any share address in, the canonical one out; the whole quote list each time, each photo resent as read, because a picture is uploaded on the dashboard alone
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(sidebarItems: [new PortalPlacementDraft('social_links'), new PortalPlacementDraft('metrics', shown: false)], localeSwitcher: false)); // the order wanted; a part left out keeps its place at the end, shown
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(featuredPlanId: $design->planOptions[0]->value, couponBox: 'link', shareNetworks: ['x', 'telegram', 'whatsapp'], socialProof: true)); // a plan the project does not have is refused as invalid_design
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(seoIndexable: false, pixelGa4: 'G-ABC123XYZ', customCss: '.portal-card { border-width: 2px; }')); // an id in the wrong shape, or a sheet with @import, url() or anything that runs, is refused as invalid_design
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(seoTitleSuffix: 'Memberships', seoDescription: 'Weekly research notes and the members lounge.')); // the card image is uploaded on the dashboard alone
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(defaultLocale: 'es', locales: ['en', 'es'])); // the whole list; a list without its default, or empty, is refused as invalid_design
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(socialLinks: [
new PortalSocialLinkDraft('website', 'https://example.com'),
new PortalSocialLinkDraft('custom', 'https://example.com/notes', 'My notes'),
]));A draft names only what changed: null keeps a choice as it is, so a bot that answers one question
sends one field. The tagline and the second accent are cleared with PortalDesignDraft::NONE,
the empty string, since null cannot mean both. A preset recolours every colour through the core's
own rule and keeps the tagline, the links and the colour mode, so a surface sends the preset's
value and never copies a palette. The social-link row is always sent whole, because a row is
edited by adding and removing links; a custom link needs a label, a named network ignores one.
The core rebuilds and validates the whole document on every save, so a colour that is not a hex
code, a tagline past its length or an address without a scheme is refused with the sentence the
dashboard would show, and the draft is left as it was.
publish(), discardDraft() and resetSection()
$design = $this->designs->publish($creator, $project); // the draft becomes the published design
$design = $this->designs->discardDraft($creator, $project); // the draft goes, the published design is shown again
$design = $this->designs->resetSection($creator, $project, 'brand'); // one section back to the defaults, in the draftpublish() and discardDraft() refuse with no_draft when nothing waits. A reset names a section
from PortalDesignDetails::$sections (brand, colours, typography, shape, layout, copy, blocks, extras, announcement, media, sections, behaviour, visibility, language) and leaves the other sections as drafted.
Refusals
Every refusal is a PortalDesignRefused with a stable reason beside the core's sentence:
| Reason | When |
|---|---|
project_unknown | The creator is unknown, or may not see the project. Answer it as missing information. |
forbidden | The creator may see the project but not update it (a teammate without the permission). |
invalid_design | A value the portal cannot draw; the sentence names the choice. |
no_draft | A publish or a discard with nothing drafted. |
section_unknown | A reset naming a section the catalogue does not carry. |
Declaring it
A creator surface that offers the designer declares portal_design among its
management_commands, and
ManagementSurface::commands() returns ManagementCommand::PortalDesign with it. The Telegram
connector's platform bot is the reference: a 🎨 Portal Design button on the project card opens
a home screen with the Brand and Colours screens, the preview, Publish and Discard.
How is this guide?
Last updated on
Copilot
Subscriby Copilot as a connector's creator surface reaches it — where the creator stands, a message that opens the connector's own thinking display at once and runs on the queue, a confirmation card answered by its id, and the surface port the answer streams back through.
Conformance Kit
The suite every connector passes before review — what "passes the kit" means, how it runs, how a rule fails, the report it returns, and the twenty-three rules grouped by what they check.