Introduction
Updating my freelance website used to mean diving into code. I rebuilt it on Sulu so I could edit pages, projects, and testimonials from an admin instead — something that still looks professional to clients, but lets me change copy and images without opening the codebase every time.
The public site is straightforward: home, about, services, a project portfolio, testimonials, contact. Behind it is Sulu, where I log in, edit fields, and publish. Visitors never see the admin.
This article covers why I chose Sulu and how I set it up. I built the structure myself as a developer; if you are not, you may still find the why useful when planning your own site.
Why Symfony and Sulu
Page builders and themes were quick to start but limited how much I could control. WordPress and headless options felt like the wrong shape for a small website — too much plugin or pipeline overhead for what I actually needed.
Sulu sat in the middle. It runs on Symfony, separates pages from repeatable entries, and ships with block-based layouts, preview, and publish built in. I define the templates once; routine updates stay in the admin.
The tradeoff is upfront build work — blocks and components have to exist before editing feels effortless. For a site I update regularly, that one-time cost was worth it.
There is a longer-term benefit too: Symfony is a powerful open source framework in its own right. The site is a CMS today, but if I need something more involved later — integrations, custom workflows, APIs — I am not boxed in by a page builder or plugin stack. The foundation is already there.
How the content is created
When I planned the site, I split content into two types: pages and entries.
- Pages are the fixed parts of the site: Home, About, Services, Contact, and similar. I built each layout once.
The CMS holds the parts that change, like headlines and intro text. Publish a page and the live site updates.
- Projects are case studies (title, description, hero image, optional screenshots).
- Services are short listings with detail pages.
- Testimonials hold a client name, quote, optional logo or photo, and date.
Each type has its own section in the admin. I did not want them mixed in with generic blog posts.
Adding a new project is not rebuilding a page. The site already knows how to display a project or a testimonial. I add an entry, fill the fields, and publish.
You add these like rows in a structured list, not by redesigning a page each time. The site already knows how to display “a project” or “a testimonial”; you just fill in the blanks.
The payoff is that routine updates need no development work. The frontend uses reusable components to render projects, services, and testimonials. When I publish a new entry or edit an existing one, listing pages, detail views, and homepage previews update automatically. I do not open the layout code for that.
The same applies to navigation. The menu at the top of the public site follows the page tree in the admin. If a page is published in the right place, it appears in the menu. Unpublish it and the link disappears. I do not maintain menu links separately or redeploy when the structure changes.
Creating new pages
When I add a new page in the webspace, I set the path, title, and headings, preview it, then publish when it looks right. The page body is built from article blocks that use components I defined upfront. I choose the block, fill the fields, and the layout stays consistent.
- I can decide if I want this page to be visible in the header navigation by checking a box.
- Preview lets me check the page on the site before anyone else sees it. I publish only when the copy and layout look right.
- Until a page is published, it stays a draft. Unpublish it and it drops off the live site (and out of the menu if it was listed there).
- The Article Body section is a stack of blocks. I can add, remove, and reorder them without touching code.
- Each block type maps to a component I already built e.g. text, headings, promo cards, images, galleries, columns, buttons, and similar. I just fill in the input fields.
- SEO and Excerpt tabs sit alongside the content, so meta titles and share images are part of the same edit, not an afterthought.
- I can add smart content that lists predefined entities, for example, I might want to show articles, or a list of services.
Roles and permissions
Sulu includes roles and permissions, which I set up even though I am the only editor today. If I bring in a content writer later, they get their own login and a role that limits what they see in the admin.
- Roles control which areas of the admin are visible (pages, projects, testimonials, and so on).
- A content writer can edit portfolio copy without access to site settings, user management, or contact submissions.
- I stay in control of structure and access; they stay focused on content.
Defining the config and templates
Behind every page in the admin is a config file I had to design upfront. It decides the structure of the page, it allows me to add headings, a list of components and specific options for certain pages. This is done once, I only need to fill in the fields when creating new entries.
I built those forms as reusable pieces so the same options appear on every page and article type. A promo card works the same on the homepage as inside a column. That keeps the admin predictable and stops the public site drifting into a mess of one-off layouts.
[Component] Buttons is a good example. If anywhere on the site I need a button to link somewhere, I can very easily add one. All that is required is for me to fill in the text, the link, and to pick a style. I can adjust it sizes so it perfectly fits the content.
Here is the Columns block definition from config/templates/blocks/button.xml.
button.xml
<?xml version="1.0" ?>
<template xmlns="http://schemas.sulu.io/template/template"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xi="http://www.w3.org/2001/XInclude"
xsi:schemaLocation="http://schemas.sulu.io/template/template http://schemas.sulu.io/template/template-1.0.xsd">
<key>button</key>
<meta>
<title lang="en">[Component] Button</title>
</meta>
<properties>
<xi:include href="../includes/bootstrap_grid.xml"
xpointer="xmlns(sulu=http://schemas.sulu.io/template/template) xpointer(/sulu:properties/sulu:property)"/>
<property name="text" type="text_line" mandatory="true">
<meta>
<title lang="en">Text</title>
</meta>
<tag name="sulu.block_preview" priority="256"/>
</property>
<property name="url" type="link">
<meta>
<title lang="en">Link</title>
</meta>
</property>
<property name="button_type" type="single_select">
<meta>
<title lang="en">Button type</title>
</meta>
<params>
<param name="default_value" value="btn-primary"/>
<param name="values" type="collection">
<param name="btn-primary">
<meta>
<title lang="en">Primary</title>
</meta>
</param>
<param name="btn-secondary">
<meta>
<title lang="en">Secondary</title>
</meta>
</param>
<param name="btn-success">
<meta>
<title lang="en">Success</title>
</meta>
</param>
<param name="btn-danger">
<meta>
<title lang="en">Danger</title>
</meta>
</param>
<param name="btn-warning">
<meta>
<title lang="en">Warning</title>
</meta>
</param>
<param name="btn-info">
<meta>
<title lang="en">Info</title>
</meta>
</param>
<param name="btn-light">
<meta>
<title lang="en">Light</title>
</meta>
</param>
<param name="btn-dark">
<meta>
<title lang="en">Dark</title>
</meta>
</param>
<param name="btn-link">
<meta>
<title lang="en">Link</title>
</meta>
</param>
<param name="btn-outline-primary">
<meta>
<title lang="en">Outline primary</title>
</meta>
</param>
<param name="btn-outline-secondary">
<meta>
<title lang="en">Outline secondary</title>
</meta>
</param>
</param>
</params>
</property>
</properties>
</template>
Here is the Twig template that renders those columns on the live site.
Button.html.twig
{#
Bootstrap call-to-action button.
Parameters:
- text (string, required): Button label
- url (string, required): Link target
- type (string): Bootstrap button variant (default: btn-primary)
- class (string): Optional extra CSS classes
Render with `<twig:Button text="…" url="…" />`.
#}
{% set class = class is defined ? ' ' ~ class : '' %}
{% set type = type|default('btn-primary') %}
<a class="btn {{ type }}{{ class }} px-4 py-3" href="{{ url }}">
<i data-lucide="chevrons-right" aria-hidden="true"></i>
{{ text }}
</a>
The homepage
The homepage is not a generic page with a free-form block stack. I built it as its own layout with four fixed sections: a hero, services and expertise, projects, and testimonials. The structure and section headings stay consistent; what I change in the admin is which content fills each area.
Hero
The top of the page is a promo block with my headshot, a short intro, and a link to the contact page. The main body copy in that block is editable — I can rewrite the promo text without touching code. The layout, image, and button stay as I designed them.
Services and expertise
This section shows a short list of services pulled from my Services entries. In the admin I open the homepage, go to the Services & Expertise tab, and use the preview picker to choose which services appear and in what order. I can set a limit (for example, show the three most recent) or hand-pick specific ones. I do not retype service titles or descriptions on the homepage; the site reads them from the service entries I already maintain.
There is also an illustration beside the list. I can swap that image and its alt text from the same tab if I want a different visual.
Projects
The projects section works the same way. I choose which case studies to feature on the homepage — again by limit, sort order, or manual selection. Each card shows the project title, a short description, hero image, and link to the full case study. When I publish a new project and add it to this preview, it appears on the homepage automatically. I do not rebuild the section or copy details across pages.
Testimonials
The testimonials section follows the same pattern. I pick which client quotes to surface on the homepage. The quote, name, and optional photo come straight from the testimonial entries. Update a testimonial in its own admin section and the homepage preview reflects the change next time I publish the homepage (or immediately, depending on cache).
Why this matters
The homepage is where most visitors land, so it needs to look polished and stay current. By wiring each section to structured content rather than one-off copy, I get both: a designed layout I control, and previews that stay in sync with my portfolio. Add a project, publish it, feature it on the home page — done. No duplicate data entry and no layout code for routine updates.
Screenshots below show the homepage sections in the admin and how the preview pickers connect to services, projects, and testimonials.
Contact form and enquiries
For a freelance site, the contact page is where interest turns into a conversation. I built it as its own page template — not a generic block stack — with editable intro copy and a form wired into the rest of the site.
What visitors see
The public contact page has a heading, optional intro text, and a form with four fields: name, email, phone (optional), and message. After submitting, they see a confirmation message on the same page. If something goes wrong — a missing field, a failed security check — the form shows a clear error instead of failing silently.
Before the message is accepted, a Cloudflare Turnstile check runs in the background. It blocks most spam bots without the old “pick every traffic light” captcha experience.
What happens when someone submits
When a visitor sends a message, three things happen in order:
- Validation — required fields are checked and the Turnstile security check must pass.
- Storage — the submission is saved in the database with a timestamp.
- Email — a notification email is sent to the recipient address configured on the contact page.
I get the email in my inbox and can reply directly. The submission also stays in the admin under Contact submissions, so I have a record even if email delivery fails or I want to review past enquiries later.
What I can edit in the admin
The contact page uses the same page-editing workflow as the rest of the site. I can change the heading, intro text, and the recipient email that receives form notifications — all without touching code. If I leave the recipient blank, the site falls back to a default admin address.
The form fields themselves (name, email, phone, message) are fixed in the template I built. That keeps the layout consistent and the validation reliable. Changing which fields appear would be a developer task.
Managing submissions
In the admin, Contact submissions lists every message: name, email, phone, when it was sent, and whether I have marked it as read. I can open a submission to read the full message and delete old entries when I no longer need them. Submissions are also purged automatically after a retention period, so the database does not grow forever.
This area is restricted by role — a content writer editing portfolio copy does not get access to private enquiries.
Technical solutions
The sections above focus on what the CMS feels like to use. This section is for developers who want the stack underneath — what I built with and how the pieces fit together.
Backend stack
| Layer | Choice |
|---|---|
| CMS | Sulu 3 on Symfony 7 |
| Language | PHP 8.2+ |
| Database | PostgreSQL 16 |
| Runtime | Docker — FrankenPHP + Caddy (HTTPS locally and in production) |
Sulu handles content modelling, the admin UI, routing, and caching. Symfony provides the application layer — custom controllers, forms, services, and console commands sit alongside Sulu’s bundles rather than fighting them.
Smart content powers homepage previews and dynamic lists — filtered article queries configured in the template, rendered in Twig. The same preview-picker pattern is reused wherever the site needs to surface projects, services, or testimonials without duplicating entries.
Frontend
| Piece | Role |
|---|---|
| Symfony Asset Mapper | Serves CSS and JS without a separate webpack build step |
| Bootstrap 5 | Layout grid, typography baseline, form styling |
| Stimulus | Small controllers for contact form, lightbox, icons, and similar interactions |
| Lucide | Icon set (loaded via Stimulus) |
| Custom CSS | Design tokens and per-component styles layered on top of Bootstrap |
SEO and social sharing
Meta tags render from a project override of Sulu’s default SEO output. Title, description, canonical URL, and robots directives come from the CMS SEO tab; Open Graph and Twitter Card tags mirror those values for link previews on LinkedIn, Slack, and similar.
Fallback order means a page with only an excerpt or body copy still produces a reasonable description and share image. JSON-LD structured data is included for search engines.
Quality and testing
The site is linted and tested as part of normal development:
- PHPUnit — acceptance tests boot the website kernel and assert HTTP output (SEO meta, components, navigation); unit tests cover services and builders
- PHPStan (max level), PHP CS Fixer, twig-cs-fixer, Rector — PHP and Twig static analysis and style
- ESLint +
@html-eslint— JavaScript and HTML structure in Twig templates
A minimum test coverage baseline prevents regressions when behaviour changes.
Hosting and operations
Production runs on Ubuntu with Docker Compose — the same stack as local development. A bootstrap script installs Docker, configures the environment, and registers systemd units so the site starts on boot. Caddy terminates HTTPS; FrankenPHP serves the Symfony app.
Day-to-day operations — starting containers, clearing cache, backing up the database and media — are wrapped in Make targets. Database and media backups are stored on the host. Contact submissions purge on a cron schedule after a configurable retention period.