Skip to content

A Gentle Introduction to Symfony

Chapter 21: A Gentle Introduction to Symfony

Overview

After exploring Laravel, it's time to see another perspective on framework design. Symfony is the other major player in the PHP framework world. While Laravel prioritizes developer convenience and "magic," Symfony prioritizes explicitness, flexibility, and a strict, object-oriented design.

What makes Symfony special is that it's not just a framework—it's also a set of decoupled, reusable components. Many other PHP projects, including Laravel itself, are built using Symfony components under the hood! Learning Symfony gives you a deep understanding of how modern PHP applications are constructed at a fundamental level.

In this chapter, you'll build a working blog post display feature using Symfony, exploring its component-based architecture, powerful code generation tools, and explicit configuration approach. By the end, you'll understand how Symfony's philosophy differs from Laravel's and when each framework shines.

Prerequisites

Before starting this chapter, make sure you have:

  • PHP 8.4 installed and available in your terminal
  • Composer 2.x installed (getcomposer.org)
  • Symfony CLI installed (symfony.com/download)
  • Completed Chapter 20 or comfortable with MVC concepts
  • A text editor and terminal
  • Estimated time: 40–50 minutes (hands-on + exploration)

What You'll Build

By the end of this chapter, you'll have:

  • A new Symfony 7 project created with the Symfony CLI
  • A Post entity and database table managed via Doctrine migrations
  • A Twig template displaying posts retrieved from the database
  • A controller and route handling the /posts endpoint
  • Sample fixture data to verify everything works end-to-end
  • An understanding of how Symfony maps to the MVC concepts you've already learned

Quick Start

If you want to get a Symfony app running immediately:

bash
# Install Symfony CLI from https://symfony.com/download
# Then create and start a new project
symfony new symfony-blog --webapp
cd symfony-blog
symfony server:start

Visit https://127.0.0.1:8000 to see the welcome page. For a complete walkthrough, continue to the step-by-step sections below.

Objectives

  • Understand Symfony's component-based philosophy
  • Install a new Symfony project using the Symfony CLI
  • Use the MakerBundle to generate entities, migrations, and controllers
  • Define routes using PHP 8 attributes
  • Use the Doctrine ORM to interact with the database
  • Render views using the Twig templating engine
  • Compare Symfony's explicit approach with Laravel's conventions

Step 1: Installing Symfony (~5 min)

Goal: Install the Symfony CLI and create a new Symfony web application skeleton with all necessary components.

Symfony has its own dedicated command-line tool that makes creating and managing projects easy. The CLI handles project creation, server management, and provides helpful debugging tools.

Actions

  1. Install the Symfony CLI:

    Follow the official installation instructions for your operating system: https://symfony.com/download

    For most systems, you can use the quick install command:

    bash
    # macOS/Linux
    curl -sS https://get.symfony.com/cli/installer | bash

    On Windows, download the .exe installer from the Symfony website.

  2. Verify the installation:

    bash
    # Check that Symfony CLI is available
    symfony --version
  3. Create a new Symfony project:

    The --webapp flag includes all standard packages for a web application (routing, templating, ORM, forms, security, etc.).

    bash
    # Create a new Symfony project with all web app components
    symfony new symfony-blog --webapp
  4. Navigate into the project and start the server:

    bash
    cd symfony-blog
    # Start the local development server
    symfony server:start

Expected Result

  • The Symfony CLI installation completes without errors
  • symfony --version displays a version number (e.g., Symfony CLI version 5.x.x)
  • The symfony new command creates a new directory called symfony-blog with a complete Symfony application structure
  • The development server starts successfully and displays a URL (typically https://127.0.0.1:8000)
  • Visiting the URL in your browser shows the Symfony welcome page with a rocket ship icon and "Welcome to Symfony" message

How It Works

The Symfony CLI is a standalone binary that wraps common Symfony tasks. The --webapp flag installs the symfony/webapp-pack, which is a meta-package that pulls in essential bundles like Twig (templating), Doctrine (ORM), Symfony Forms, Security, and more. This is different from symfony new my-project without flags, which creates a minimal skeleton suitable for APIs or microservices.

The Symfony server uses PHP's built-in web server but adds TLS/SSL support automatically, making your local URLs use https:// instead of http://.

Troubleshooting

Problem: symfony: command not found after installation

Solution: The Symfony CLI binary needs to be in your PATH. For macOS/Linux:

bash
# Add to your ~/.bashrc, ~/.zshrc, or equivalent
export PATH="$HOME/.symfony5/bin:$PATH"
# Then reload your shell
source ~/.bashrc  # or source ~/.zshrc

Problem: PHP extension ext-ctype is missing or similar extension errors

Solution: Symfony requires several PHP extensions. Install them using your package manager:

bash
# Ubuntu/Debian
sudo apt install php8.4-cli php8.4-xml php8.4-mbstring php8.4-intl php8.4-sqlite3

# macOS with Homebrew
brew install php@8.4

Problem: Port 8000 is already in use

Solution: Either stop the other service using port 8000, or start Symfony on a different port:

bash
symfony server:start --port=8001

Problem: Browser shows SSL certificate warning

Solution: This is normal for local development. The Symfony CLI generates a self-signed certificate. You can safely click "Advanced" and "Proceed" in your browser. Alternatively, install the local certificate authority:

bash
symfony server:ca:install

Step 2: Configure the Database (~3 min)

Goal: Set up SQLite as the database and create the database file using Doctrine.

Symfony uses Doctrine as its ORM (Object-Relational Mapper). Unlike Laravel's Eloquent, which uses the Active Record pattern, Doctrine uses the Data Mapper pattern, keeping your entities separate from database logic.

Actions

  1. Configure the database connection:

    Open the .env file in the project root. Find the DATABASE_URL line and replace it with SQLite configuration:

    bash
    # filename: .env
    # Comment out the default MySQL configuration
    # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
    
    # Add SQLite configuration
    DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
  2. Create the database:

    bash
    # Create the SQLite database file
    php bin/console doctrine:database:create

Expected Result

  • The .env file now contains the SQLite DATABASE_URL
  • Running the doctrine:database:create command outputs: Created database /path/to/symfony-blog/var/data.db
  • A new file var/data.db exists in your project directory

How It Works

The %kernel.project_dir% is a Symfony parameter that resolves to your project's root directory. Doctrine reads the DATABASE_URL from the .env file and creates a SQLite database file at the specified location. SQLite is perfect for development since it requires no separate database server.

Troubleshooting

Problem: An exception occurred in driver: SQLSTATE[HY000] [14] unable to open database file

Solution: Ensure the var/ directory exists and is writable:

bash
mkdir -p var
chmod 755 var

Step 3: Create the Post Entity (~4 min)

Goal: Generate a Post entity with title and content fields, and create the corresponding database table.

In Symfony, a "Model" is called an Entity. The MakerBundle provides an interactive command to generate entities with all necessary annotations.

Actions

  1. Generate the Post entity:

    bash
    # Start the interactive entity generator
    php bin/console make:entity Post

    The command will prompt you to add properties. Enter the following:

    • Property name: title
    • Field type: string
    • Field length: 255
    • Can this field be null in the database: no

    Then add another property:

    • Property name: content
    • Field type: text
    • Can this field be null in the database: no

    Press Enter (empty property name) to finish.

  2. Review the generated entity:

    Open src/Entity/Post.php to see the generated code. Notice the PHP attributes (like #[ORM\Entity] and #[ORM\Column]) that tell Doctrine how to map this class to a database table.

  3. Create and run the migration:

    bash
    # Generate a migration file based on entity changes
    php bin/console make:migration
    
    # Apply the migration to create the posts table
    php bin/console doctrine:migrations:migrate

    Type yes when prompted to execute the migration.

Expected Result

  • make:entity creates src/Entity/Post.php with $id, $title, and $content properties, plus getter and setter methods
  • make:migration creates a new file in migrations/ directory (e.g., VersionXXXXXXXXXXXXXX.php)
  • doctrine:migrations:migrate outputs Migration VersionXXXX executed and creates a post table in the database

Validation

Check that the table was created:

bash
# List all tables in the database
php bin/console doctrine:query:sql "SELECT name FROM sqlite_master WHERE type='table'"

You should see post in the output.

How It Works

The MakerBundle generates entity classes with Doctrine attributes that describe the database schema. The make:migration command compares your entities against the current database schema and generates SQL to synchronize them. This migration-based approach allows you to version control your database schema changes, similar to Git for code.

Troubleshooting

Problem: The command make:entity does not exist

Solution - The MakerBundle might not be installed. Install it:

bash
composer require symfony/maker-bundle --dev

Problem: Migration fails with Syntax error or access violation

Solution: Delete the var/data.db file and the migrations/ directory, then try again from the database creation step:

bash
rm var/data.db
rm -rf migrations/
php bin/console doctrine:database:create
php bin/console make:migration
php bin/console doctrine:migrations:migrate

Step 4: Create the Controller and Route (~3 min)

Goal: Generate a controller and define a route that displays a single blog post.

Symfony uses PHP 8 attributes to define routes directly above controller methods, keeping routing logic colocated with the controller code.

Actions

  1. Generate the PostController:

    bash
    # Create a new controller
    php bin/console make:controller PostController

    This creates src/Controller/PostController.php and templates/post/index.html.twig.

  2. Add the show method:

    Open src/Controller/PostController.php and replace its contents with:

    php
    <?php
    // filename: src/Controller/PostController.php
    namespace App\Controller;
    
    use App\Entity\Post;
    use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\Routing\Attribute\Route;
    
    class PostController extends AbstractController
    {
        #[Route('/posts/{id}', name: 'post_show')]
        public function show(Post $post): Response
        {
            // Symfony automatically queries the database for a Post
            // with an ID matching {id} from the URL.
            // This feature is called automatic entity resolution.
    
            return $this->render('post/show.html.twig', [
                'post' => $post,
            ]);
        }
    }

Expected Result

  • The src/Controller/PostController.php file contains a show method with a #[Route] attribute
  • The method accepts a Post parameter and returns a Response

How It Works

Symfony's routing system scans your controllers for #[Route] attributes. When a request matches /posts/42, Symfony extracts 42 as the id parameter. Because the method signature declares Post $post, Symfony's ParamConverter automatically queries the database for Post with id = 42 and injects it into the method. If no post is found, Symfony automatically returns a 404 response.

This is more explicit than Laravel's route model binding but offers similar convenience.

Troubleshooting

Problem: Cannot autowire argument $post of type Post

Solution: Ensure you imported the entity at the top of your controller:

php
use App\Entity\Post;

Problem: Routes not found or 404 errors

Solution: Clear the Symfony cache:

bash
php bin/console cache:clear

Step 5: Create the Twig Template (~2 min)

Goal

Create a Twig template to display the blog post's title and content.

Symfony uses Twig, a powerful templating engine with its own syntax. It's sandboxed (safer) and more feature-rich than Blade, though the syntax differs slightly.

Actions

  1. Create the template file templates/post/show.html.twig with the following content:
twig
{# filename: templates/post/show.html.twig #}
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>{{ post.title }}</title>
    <style>
        body { font-family: system-ui; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
        h1 { color: #000; border-bottom: 2px solid #000; padding-bottom: 0.5rem; }
    </style>
</head>
<body>
    <h1>{{ post.title }}</h1>
    <div>{{ post.content|nl2br }}</div>
</body>
</html>

Expected Result

  • The templates/post/show.html.twig file exists.
  • The template uses Twig syntax with double curly braces for output and the nl2br filter for formatting.

Why It Works

  • The double curly braces with post.title outputs the post's title. In Twig, post.title automatically calls the entity's getTitle() method.
  • The double curly braces with post.content|nl2br applies the nl2br filter, converting newlines to <br> tags.
  • Twig automatically escapes output to prevent XSS, making templates safe by default.

Troubleshooting

  • Template shows the Twig syntax literally — Ensure the file has a .twig extension and lives under templates/. Clear the cache with php bin/console cache:clear.
  • Styles not applied — Confirm inline styles are present or move them to an external stylesheet.

Step 6: Add Test Data and View Your Page (~2 min)

Goal: Insert a sample blog post and view it in the browser.

Actions

  1. Insert a test post:

    bash
    # Add a sample blog post directly via SQL
    php bin/console doctrine:query:sql "INSERT INTO post (title, content) VALUES ('My First Symfony Post', 'This is the content of my first post using Symfony and Doctrine ORM!')"
  2. Visit the page:

    Open your browser and navigate to:

    https://127.0.0.1:8000/posts/1

Expected Result

  • The SQL command outputs: 1 row(s) affected
  • The browser displays a page with "My First Symfony Post" as the heading
  • The content is displayed below the heading
  • The URL shows no errors (no 404 or 500)

Validation

Verify the data was inserted:

bash
# Query all posts
php bin/console doctrine:query:sql "SELECT * FROM post"

You should see your post with id = 1.

How It Works

Symfony's routing matched your URL /posts/1 to the post_show route. The ParamConverter loaded the Post entity with id = 1 from the database, and the controller passed it to the Twig template for rendering. All the pieces—routing, controller, ORM, and templating—work together seamlessly.

Troubleshooting

Problem: 404 Not Found error

Solution: Double-check the URL is https://127.0.0.1:8000/posts/1 (with https and the correct port). Verify the route exists:

bash
php bin/console debug:router | grep post_show

Problem: 500 error with "Unable to find template"

Solution: Ensure templates/post/show.html.twig exists and is spelled correctly in the controller.

Problem: 404 with "Post object not found"

Solution: Verify the post exists in the database:

bash
php bin/console doctrine:query:sql "SELECT * FROM post WHERE id = 1"

If no results, re-run the INSERT command from step 1.

Explore the Debug Toolbar

Now that you have a working page, look at the bottom of your browser window. You should see a black toolbar with icons—this is Symfony's Web Debug Toolbar.

Try clicking on:

  • The clock icon (⏱️) to see page load time and performance metrics
  • The database icon (🗄️) to see the exact SQL query Symfony executed
  • The Twig icon to see which templates were rendered

This toolbar is only visible in development mode and is one of Symfony's most powerful debugging features. We'll explore it more in the next section.

Understanding Symfony's Architecture

Before comparing frameworks, let's briefly cover a few Symfony-specific concepts you've been using without realizing it. Understanding these will help you appreciate Symfony's philosophy and power.

Bundles: Symfony's Building Blocks

In Symfony, functionality is organized into bundles. A bundle is like a plugin or package—a self-contained collection of code, configuration, and resources that adds features to your application.

You've already used bundles:

  • FrameworkBundle: Core Symfony features (routing, controllers, services)
  • TwigBundle: Twig templating integration
  • MakerBundle: Code generation commands
  • DoctrineBundle: Database integration

Bundles make Symfony highly modular. You can add authentication, REST APIs, admin panels, or payment processing by simply installing and configuring the appropriate bundle.

Key Insight: Laravel has "packages" with a similar concept, but Symfony's bundle system is more deeply integrated into the framework's architecture.

The Service Container and Dependency Injection

Behind the scenes, Symfony uses a powerful Service Container to manage all the objects (services) your application needs. When you saw Symfony automatically inject the Post entity into your controller, you witnessed this in action.

The Service Container:

  • Creates and configures objects for you
  • Handles dependencies automatically (Dependency Injection)
  • Ensures services are only created when needed (lazy loading)
  • Makes testing easier by allowing service replacement

Example: When you type-hinted Post $post in your controller, Symfony's ParamConverter service automatically queried the database and injected the result. You didn't have to manually request or configure this—the container handled it.

This is more explicit than Laravel's "magic" approach and gives you fine-grained control when needed.

Symfony Flex: The Modern Experience

When you used symfony new --webapp, you were actually using Symfony Flex, a Composer plugin that streamlines Symfony development. Flex:

  • Automatically installs and configures bundles (called "recipes")
  • Keeps your project structure clean and organized
  • Updates configuration files for you
  • Makes Symfony feel as fast and modern as any framework

This is why modern Symfony feels so different from older versions—Flex transformed the developer experience.

The Debug Toolbar and Profiler

One of Symfony's killer features is its Web Debug Toolbar and Profiler. If you look at the bottom of your development pages, you'll see a black toolbar with icons showing:

  • Request/Response information: HTTP status, method, route name
  • Performance metrics: Page load time, memory usage
  • Database queries: Every query executed, with timing and EXPLAIN data
  • Twig templates: Which templates were rendered
  • Events and logs: Everything that happened during the request

Click any icon to open the Profiler, a detailed view where you can debug every aspect of your request. This is invaluable for troubleshooting and optimization.

Pro Tip: The profiler stores the last 25 requests, so you can review API calls or form submissions even after they complete.

Configuration: YAML, PHP, or Attributes

Symfony is highly configurable, and you can use three different formats:

  1. YAML files (in config/ directory) - Most common, human-readable
  2. PHP files - For complex configuration logic
  3. Attributes (like #[Route]) - For route and validation configuration

You used PHP attributes for routes. This "configuration as code" approach is modern and type-safe.

Laravel vs. Symfony: A Quick Comparison

FeatureLaravel (The "Artisan")Symfony (The "Architect")
Philosophy"Convention over Configuration." Prefers magic and speed."Explicitness is better than implicitness." Prefers structure.
ORMEloquent: Active Record pattern. Easy and fast.Doctrine: Data Mapper pattern. Powerful and flexible.
TemplatingBlade: Simple, clean, and directly uses PHP expressions.Twig: More feature-rich, sandboxed, and has its own syntax.
StructureOpinionated and provides a clear path.Unopinionated and flexible; you build it from components.
Learning CurveLower for beginners.Steeper, but teaches deep OOP and design principles.

Wrap-up

Congratulations! You've successfully built your first Symfony application and displayed a blog post using modern PHP practices. Here's what you've accomplished:

What You've Learned:

  • Installed and configured a Symfony project using the Symfony CLI
  • Set up SQLite as a database using Doctrine ORM
  • Generated entities, migrations, and controllers using the MakerBundle
  • Defined routes using PHP 8 attributes
  • Created views using the Twig templating engine
  • Understood Symfony's Data Mapper pattern (Doctrine) vs. Laravel's Active Record pattern (Eloquent)

Key Takeaways:

  • Symfony prioritizes explicitness over convention, giving you full control and transparency
  • The MakerBundle is incredibly powerful for scaffolding code
  • Doctrine's Data Mapper pattern separates your domain models from persistence logic
  • Twig offers a feature-rich, sandboxed templating environment
  • PHP 8 attributes make routing configuration colocated and type-safe

When to Choose Symfony:

  • Large, complex enterprise applications requiring flexibility
  • Projects needing fine-grained control over every component
  • Teams that value explicit configuration and strict patterns
  • Applications that will scale to millions of users
  • Projects where you want to deeply understand what's happening under the hood

When to Choose Laravel:

  • Rapid application development with tight deadlines
  • Startups and MVPs that prioritize speed to market
  • Teams new to PHP frameworks
  • Projects that benefit from strong conventions and "magic"
  • Applications with typical CRUD operations

Both frameworks are excellent choices and power thousands of production applications. The fundamental concepts—routing, controllers, ORMs, templating—are universal. Learning both makes you a more versatile developer.

Exercises

To deepen your understanding of Symfony, try these challenges:

  1. Add a List View: Create a route at /posts that displays all blog posts. Use Doctrine's repository methods to fetch all posts.

    Hint: In your controller, inject EntityManagerInterface or use the repository: $this->getDoctrine()->getRepository(Post::class)->findAll()

  2. Add Timestamps: Add createdAt and updatedAt fields to the Post entity. Use Doctrine's lifecycle callbacks or the Gedmo Timestampable extension.

  3. Form Handling: Create a form to add new posts through the web interface instead of SQL commands. Use php bin/console make:form and explore Symfony's Form component.

  4. Validation: Add validation to your Post entity using Symfony's validator constraints. Ensure the title is at least 5 characters and the content is not empty.

    Hint: Use validation attributes like #[Assert\NotBlank] and #[Assert\Length(min: 5)]

  5. Service Creation: Create a custom service class (e.g., PostStatisticsService) that calculates post counts and average content length. Inject it into your controller and display the statistics on the index page.

    This will teach you about Symfony's Service Container and dependency injection.

Further Reading

In the final chapter of this series, we'll summarize everything you've learned across all 22 chapters and provide a comprehensive roadmap for continuing your journey to becoming an expert PHP developer.