Frontmatter

Integrating an Astro frontend with a Symfony backend

How to hand off an Astro project to a Symfony developer — Twig templates, controller variables, and the integration pattern that keeps both sides clean.

Symfony and Astro are a natural pair — Astro for building UI, Symfony for application logic, Twig as the shared rendering layer between them.

The challenge is the handoff. This post covers the integration pattern that keeps both sides clean.

The integration model

Astro → Solo → Twig templates → Symfony controller → rendered page.

The Astro project is the design source. Solo translates it to Twig. The Symfony controller provides the data. Twig renders it.

None of these steps are coupled. The Astro developer doesn’t need to know Symfony. The Symfony developer doesn’t need to read Astro.

What the Twig output looks like

A Symfony-compatible Twig template follows the standard extends / block pattern:

{# templates/product/index.twig — generated by FM Solo #}
{% extends 'layouts/base.twig' %}

{% block title %}{{ fm.page.title }}{% endblock %}

{% block content %}
  {% include 'partials/hero.twig' with { fm: { props: fm.data.hero } } %}

  {% for product in fm.data.products %}
    {% include 'partials/product-card.twig' with { fm: { props: product } } %}
  {% endfor %}
{% endblock %}

The base layout:

{# templates/layouts/base.twig — generated by FM Solo #}
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>{% block title %}{% endblock %}</title>
  </head>
  <body>
    {% include 'partials/nav.twig' with { fm: { props: fm.data.nav } } %}
    {% block content %}{% endblock %}
    {% include 'partials/footer.twig' with { fm: { props: fm.data.footer } } %}
  </body>
</html>

The Symfony controller

The controller’s only responsibility is passing the right variables to the template:

<?php
// src/Controller/ProductController.php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class ProductController extends AbstractController
{
    #[Route('/products', name: 'product_index')]
    public function index(): Response
    {
        return $this->render('product/index.twig', [
            'fm' => [
                'page' => ['title' => 'Our products'],
                'data' => [
                    'hero'     => [
                        'headline' => 'Our products',
                        'sub'      => 'Everything we make, in one place.',
                    ],
                    'products' => $this->getProducts(),
                ],
            ],
        ]);
    }
}

The controller passes variables under the fm namespace. The fm.page key carries page-level data, fm.data carries template-specific data, and partials receive their data as fm.props. The INTEGRATION.md generated by Solo documents what each template expects — the Symfony developer reads it once, writes the controller, done.

What INTEGRATION.md tells the Symfony developer

## Template: product/index.twig

Variables passed from controller:

| Variable          | Type     | Required | Description              |
|-------------------|----------|----------|--------------------------|
| fm.page.title     | string   | yes      | HTML title tag           |
| fm.data.hero.headline | string | yes   | Main heading             |
| fm.data.hero.sub      | string | no    | Subheading               |
| fm.data.products      | array  | yes   | Array of product objects |
| fm.data.products[].name  | string | yes |                         |
| fm.data.products[].price | float  | yes |                         |
| fm.data.products[].image | string | no  | Image URL               |

No ambiguity. No back-and-forth.

Dropping the templates into Symfony

The generated output maps directly to Symfony’s templates/ directory:

output/
├─ pages/index.twig    → templates/product/index.twig
├─ layouts/base.twig   → templates/layouts/base.twig
└─ partials/hero.twig  → templates/partials/hero.twig

Copy the files. Update the extends paths if your template namespace differs. Wire the controller variables per INTEGRATION.md. Done.

Generating the Twig output

Frontmatter Solo generates the full Symfony-compatible render pack from your Astro source:

frontmatter solo:build --adapter twig

Output:

output/
├─ pages/
├─ layouts/
├─ partials/
├─ manifest.json
└─ INTEGRATION.md

The manifest.json is machine-readable — you can use it to validate controller variables automatically or generate Symfony form types if needed.

The clean boundary

The Astro developer and the Symfony developer never need to talk about template details. Solo handles the translation. The INTEGRATION.md handles the communication.

Both sides work from the same contract — generated from the Astro source, not negotiated in a Notion doc.

Frontmatter Solo does not generate Symfony controllers or application logic. It generates Twig templates and their expected data contract only.


Scope

Frontmatter Solo:

  • generates templates (Twig or PHP)
  • defines a variable contract (manifest + INTEGRATION.md)

It does not:

  • integrate with your backend
  • fetch data
  • configure a CMS
  • provide runtime behavior

Integration is explicit and owned by the host application.