A modern, production-ready Symfony boilerplate for building payment-enabled storefronts with Stripe.
This project provides a robust foundation for integrating Stripe Checkout, Customer Portals, and Webhooks while leveraging the latest Symfony 8 features and FrankenPHP.
- Stripe Integration:
- π Dynamic Product Catalog: Fetches products and prices directly from Stripe with local caching for optimal performance.
- π Advanced Search & Filtering: Fast, metadata-aware search that scans product names, descriptions, and custom Stripe metadata.
- π·οΈ Price Type Toggle: Modern UI to quickly switch between one-time purchases and recurring subscriptions.
- π³ Stripe Checkout: Seamlessly handles one-time payments and recurring subscriptions with pre-built Stripe pages.
- π Customer Portal: Pre-configured access for customers to manage their billing, view invoices, and update subscriptions.
- β Webhook Handling: Robust webhook controller for processing payment success, subscription updates, and cancellations in real-time.
- π¦ Order History & Entitlements: Automatically tracks purchases and subscriptions locally as
Orderentities for easy access and entitlement checks. β₯οΈ Wishlist: Users can save products for later, it set a cookie that persists across sessions for 1 year.- π§ Single product mode: Hide product catalog and redirect to a single product page, useful for single-product stores or demos.
- Authentication & Security:
- π Passwordless Login: Modern magic link authentication systemβno passwords to remember, just simple email-based sign-ins.
- π€ Automatic User Provisioning: New users are automatically created upon successful Stripe checkout, ensuring a seamless onboarding flow.
- π‘οΈ CSRF Protection: Secure form handling across the application.
- Modern Frontend: Built with Symfony UX, Tailwind CSS v4, Stimulus, and Turbo.
- β¨ Polished UI: Sleek, responsive design using a refined Slate color palette, sophisticated typography, and smooth transitions.
- π Collapsible Search: Minimalist interface that stays collapsed by default to focus on products, expanding automatically when filters are active.
- π± Mobile Optimized: Fully responsive layout with touch-friendly elements and adaptive spacing for all screen sizes.
- β‘ No-Build Workflow: Powered by AssetMapperβmodern JavaScript and CSS management without the overhead of Node.js build steps.
- Developer Experience:
- β‘ FrankenPHP: High-performance PHP application server with built-in Caddy and automatic HTTPS.
- π³ Dockerized: Fully containerized environment for consistent development and easy production deployment.
- π Multilingual: Internationalized (i18n) using Symfony Translation. Supports English, Spanish, and Italian with a session-based language switcher.
- ποΈ SQLite by Default: Zero-config database setup for lightning-fast prototyping and local development, with optional Litestream replication for production persistence.
- PHP 8.4+
- Symfony 8.0
- Stripe PHP SDK
- FrankenPHP (with Caddy)
- AssetMapper (Modern JS/CSS management)
- Tailwind CSS
- Doctrine ORM (SQLite)
- Docker and Docker Compose installed.
- A Stripe Account (for API keys).
Clone the repository:
git clone https://github.com/0franco/stripe-php-frontend
cd stripe-php-frontendCopy the example environment file:
cp .env .env.localOpen .env.local and set the following variables:
# Required: Set these to your Stripe keys
STRIPE_SECRET_KEY=sk_test_...
# Required: Set to allow user provisioning and order linking
STRIPE_WEBHOOK_SECRET=whsec_...
# Required: To use email notifications, user needs it to login
MAILER_DSN=resend+api://your-api-key@default
# Optional: Set this to a comma-separated list of product IDs to show only those products, requires SHOW_ALL_PRODUCTS=false
ALLOWED_PRODUCT_IDS=prod_1,prod_2,..
# Optional: Set these to true to enable single-product mode, which hides the product catalog and redirects to a single product page, requires DEFAULT_PRODUCT_ID set
SINGLE_PRODUCT_MODE=true
# Optional: Remove any languages you don't need, defaults to es,en,it
AVAILABLE_LANGS=es,en,it
# Optional: Recommeded to be enabled for filtering login bots
CLOUDFLARE_TURNSTILE_SITE_KEY=
CLOUDFLARE_TURNSTILE_SECRET_KEY=Build and start the containers:
docker compose build --pull
docker compose up -dThe app will be available at https://localhost.
Run migrations inside the PHP container:
docker compose exec php bin/console doctrine:migrations:migrate --no-interactionThis project uses SQLite by default. Locally, the database lives at db/data.db.
In production (e.g. Render, Fly.io), containers are ephemeral β the database is wiped on every deploy unless you configure persistence.
Litestream continuously replicates your SQLite WAL to object storage. On container start, it restores the latest snapshot before the app boots. This gives you near-zero data loss with no infrastructure changes.
Why Cloudflare R2? Zero egress fees, S3-compatible API, and generous free tier.
- Go to Cloudflare Dashboard β Storage & Databases β R2 Object Storage
- Click Create Bucket, name it (e.g.
myapp-db) - On the R2 Overview page, note your Account ID (top right)
- On the R2 Overview page, click Manage API Tokens
- Click Create API Token
- Set Permissions to
Object Read & Write, scoped to your bucket - Save the Access Key ID and Secret Access Key β the secret is shown only once
Add to your .env.local (local) or your PaaS environment panel (production):
LITESTREAM_REPLICA_URL="s3://your-bucket-name/data.db"
CLOUDFLARE_ACCOUNT_ID=your-account-id
LITESTREAM_ACCESS_KEY_ID=your-access-key-id
LITESTREAM_SECRET_ACCESS_KEY=your-secret-access-keyThe LITESTREAM_REPLICA_URL uses the S3-compatible format β the R2 endpoint is configured automatically via CLOUDFLARE_ACCOUNT_ID.
When LITESTREAM_REPLICA_URL is set, the container entrypoint will:
- Restore the latest database snapshot from R2 (if no local DB exists)
- Run pending migrations
- Start Litestream replication in the background
- Boot the app normally
When the env var is not set (local dev), the app falls back to the local db/data.db file and requires manual initialization.
β οΈ Single-instance only. SQLite with Litestream does not support horizontal scaling. Keep your service at a single instance.
Same setup as above, replacing the R2-specific config:
LITESTREAM_REPLICA_URL="s3://your-s3-bucket-name/data.db"
LITESTREAM_ACCESS_KEY_ID=your-aws-access-key
LITESTREAM_SECRET_ACCESS_KEY=your-aws-secret-key
# Leave CLOUDFLARE_ACCOUNT_ID unsetUpdate frankenphp/litestream.yml to remove the endpoint and force-path-style fields β standard S3 doesn't need them:
dbs:
- path: /app/db/data.db
replicas:
- name: s3
url: ${LITESTREAM_REPLICA_URL}
access-key-id: ${LITESTREAM_ACCESS_KEY_ID}
secret-access-key: ${LITESTREAM_SECRET_ACCESS_KEY}
region: us-east-1 # your bucket regionThis app uses magic link authentication, so transactional email is required for login to work.
The included Mailpit service catches all outgoing emails. No configuration needed β visit http://localhost:8025 to view sent emails.
β οΈ PaaS platforms like Render block outbound SMTP ports (25, 465, 587) on free and starter plans. Standard SMTP will time out. You must use an HTTP API-based email provider instead.
The recommended provider is Resend β generous free tier, simple API, and first-class Symfony Mailer support.
MAILER_DSN=resend+api://your-api-key@defaultSee the Resend + Symfony docs for domain verification setup, which is required to send from your own email address.
Want to showcase just one product and skip the full catalog? Enable Single Product Mode:
-
Set these environment variables:
SINGLE_PRODUCT_MODE=true DEFAULT_PRODUCT_ID=prod_1234567890
Replace
prod_1234567890with your actual product ID. -
What happens?
All users will be automatically redirected to the page for the specified product, bypassing the catalog view.
If you want to show only a subset of products, set on your environment as below:
SHOW_ALL_PRODUCTS=false
ALLOWED_PRODUCT_IDS=prod_1234567890,prod_9876543210 #Comma separated list of product idsThis project uses the Symfony Translation component for multi-language support.
- English (
en) - Default - Spanish (
es) - Italian (
it)
- Translation Files: Located in the
translations/directory (e.g.,messages.en.yaml). - Language Switcher: A compact dropdown menu in the header allows users to switch between supported languages.
- Locale Persistence: The selected locale is stored in the user's session and applied to subsequent requests via a custom
LocaleListener. - Templates: Uses the
|transfilter in Twig templates. Thelangattribute inbase.html.twigis automatically set based on the current locale. - Controllers: The
TranslatorInterfaceis used to translate flash messages and other dynamic content.
- Create a new YAML file in
translations/using the appropriate locale code:messages.[locale].yaml. - Copy the keys from
messages.en.yamland provide the translations. - Clear the cache to register the new translation file:
docker compose exec php bin/console cache:clear
You can link products together by adding a related_ids metadata key on any Stripe product.
- Go to your Stripe Dashboard
- Open a product and scroll to Metadata
- Add a new key/value pair:
| Key | Value |
|---|---|
related_ids |
prod_xxx,prod_yyy,prod_zzz |
The value is a comma-separated list of Stripe product IDs.
related_ids = prod_ABC123,prod_DEF456
These products will appear in a Related Products carousel at the bottom of the product detail page.
- Order is preserved (left to right in carousel)
- Inactive or deleted product IDs are silently skipped
- There is no hard limit, but 4β6 items is recommended for UX
Modify footer links by changing FOOTER_LINKS env.
Simple: label,url pairs separated by |
FOOTER_LINKS="Home,/|Terms of Service,/terms, External link,https://example.com/terms"Integrated with Google Analytics, to track add the code on GA4_MEASUREMENT_ID env var.
GA4_MEASUREMENT_ID=G-XXXXXXXXXXXTo test webhooks locally, use the Stripe CLI:
stripe listen --forward-to https://localhost/webhooks/stripe --skip-verifyUpdate your STRIPE_WEBHOOK_SECRET in .env.local with the signing secret provided by the CLI.
The product catalog is cached for performance. To refresh it:
docker compose exec php bin/console app:clear-stripe-cachedocker compose exec php bin/console phpunittemplates/product/details.html.twig reads Stripe product metadata and applies specific keys to SEO:
meta_title: Used as the page<title>(fallback: Stripe product name).meta_description: Used as<meta name="description">(fallback: Stripe product description).
These SEO keys are intentionally excluded from the visible "Product details" metadata table.
The template defines a local blacklist constant:
{% set METADATA_BLACKLIST = ['meta_title', 'meta_description'] %}Add any custom metadata keys to this array to hide them from the metadata table while keeping them available in Stripe.
This project includes a compose.prod.yaml for simulating the production build locally.
- Set
APP_ENV=prodand a secureAPP_SECRET. - Use real production Stripe keys.
- Configure database persistence (see Database Persistence above).
- Configure a transactional email provider (see Email above).
- Build for production locally:
docker compose -f compose.yaml -f compose.prod.yaml build
This project is licensed under the Apache 2.0 License
