Appearance
Marketing pages
The public site is part of your edition rather than a second application. Three routes, no build of its own, and the pricing on the front page is the same catalogue as the pricing behind the login because there is only one of it.
/ marketing/landing hero, features, pricing, closing call to action
/legal/privacy legal/privacy template privacy policy
/legal/terms legal/terms template terms of serviceWhy not a separate app: a landing page that deploys on its own is a second thing to deploy, a second place to configure, and a second copy of your prices to keep correct. The cost of keeping it here is one route file entry.
Where each piece lives
| Piece | File |
|---|---|
| Routes | routes/web.php |
| Controller | app/Http/Controllers/MarketingController.php |
| Landing page | resources/js/pages/marketing/landing.tsx · .vue |
| Legal pages | resources/js/pages/legal/{privacy,terms}.tsx · .vue |
| Shell, header, footer | layouts/marketing-layout.tsx · layouts/MarketingLayout.vue, and components/marketing/ |
| Pricing table | components/pricing-table.tsx · components/billing/PricingTable.vue |
| Every sentence | lang/{locale}/app.php, under marketing and legal |
Rewriting the copy
Nothing on these pages is written in a component. The landing page is a layout and the sentences are translation keys, so the pitch is rewritten in lang/en/app.php and then in lang/es/app.php, and neither edit touches a component file.
php
'marketing' => [
'hero' => [
'title' => 'Everything your team needs, on the first day',
'tagline' => 'Sign-in, teams, permissions, subscriptions ...',
'cta' => 'Start for free',
],
],tests/Feature/I18n/TranslationParityTest.php fails when one locale has a key the other does not, so a Spanish sentence you forgot is caught rather than silently falling back to English on a page you are trying to sell from.
The feature grid
Eight entries, each a key with an icon:
tsx
// resources/js/pages/marketing/landing.tsx (React edition)
const FEATURES = [
{ key: 'accounts', icon: ShieldCheck },
{ key: 'teams', icon: Users },
// ...
];The icon is here and the words are in the language files, matched by name: app.marketing.features.items.accounts.title and .body. Removing a feature is deleting its entry from this array and its keys from both language files; a list matched by position instead would go quietly wrong the first time the two lengths disagreed.
The pricing section
PricingTable is one component with two behaviours, and the only difference is what a plan's button does.
- A guest gets a link to registration.
- Somebody signed in gets the real checkout: a form post to
billing.checkout, exactly as on the screen inside the application.
A guest is not sent to the checkout, and the reason is mechanical rather than polite. billing.checkout is a POST, so Laravel's intended redirect could not replay it after a sign-in; and with the default LARASPRING_BILLING_BILLABLE=organization the billable is the active tenant, which somebody who has never signed in does not have. Registration is the step that produces one, and the plan is chosen again inside the application against a billable that exists.
The plans come from App\Marketing\PublicPlans, which builds the same payload laraspring/billing's own controller does. Hidden plans are excluded, free and contact plans are included, and every plan goes through Plan::toArray().
No price identifier reaches the browser
That is what Plan::toArray() guarantees, and putting the catalogue on a public page does not change it: the checkout takes a plan id and looks the price up on the server. tests/Feature/Marketing/MarketingPagesTest.php asserts it, because this is the sort of rule that survives being written down only if something fails when it is broken.
Metadata and link previews
Each public page sends a meta prop, and resources/views/app.blade.php renders it into the document head:
php
'meta' => [
'title' => __('app.marketing.meta.title').' — '.config('brand.name'),
'description' => __('app.marketing.meta.description'),
'url' => $request->url(),
'image' => config('brand.og_image'),
],Server-side on purpose. A link preview is fetched by a crawler that does not run JavaScript, so a tag React sets after boot is a tag nobody scraping the page ever sees. Inertia's <Head> still sets the document title, which is what a person reads, and the two are kept in the same shape so it does not change on a client-side visit.
A screen behind the login sends no meta and gets nothing but its title, which is right: it is not a page anybody should be able to preview.
The legal pages
Both are templates, and they say so on the page.
php
'legal' => [
'privacy' => [
'sections' => [
['heading' => 'What we collect', 'body' => '...'],
],
],
],The clauses are a list rather than numbered keys, so adding one is a line in each locale and no change to the component. useTranslations().list() reads it.
This is not legal advice
What a privacy policy has to say depends on what you collect, who you send it to and where your customers live, and no starter kit can know any of that. Have a lawyer review both documents against what your product actually does.
The banner at the top of both pages tells your visitors the same thing. Remove it, in app.legal.template_title and app.legal.template_body, once that review has happened, and not before.
Removing a page
The public site is ordinary application code, so deleting it is deleting it: the route, the page component and the strings. Two things to keep in mind.
Keep the home route name. The kit redirects to it, and the paywall's locked screen links to it.
The footer links to legal.privacy and legal.terms by name. Remove one and remove its link, or guard it with route().has() as the header already does for register.