Symfony tutorial for beginners
Hey there.
In this tutorial, you will learn how to create a basic Symfony application. When you finish this tutorial, you will know how to create a simple CRUD application. And you will get familiar with controllers, templates, entities, routes, etc. And you will be ready to start working on more advanced topics.
Requirements
I assume that you have basic knowledge of these things:
- Basic PHP/MySQL knowledge
- HTML/CSS knowledge
- You should know how to use the terminal(console)
And you have installed:
- PHP 7+
- MySQL 5.6+ or MariaDB
- You are using macOS or Linux(Ubuntu, Mint .. whatever)
- If you are on Windows, you will need a XAMMP or something similar.
- Text editor - PhpStorm or Visual Studio Code.
- Also, I recommend installing Symfony plugin for PhpStorm or VSCode
Let's start.
Installation
You need to download the Symfony installer. Go to https://symfony.com/download and download it.
I am using macOS, so I will write next:
curl -sS https://get.symfony.com/cli/installer | bash
After installation you should be able to write commands like this one:
symfony help

Symfony CLI application has a lot of built-in features. But we will talk about that later. Let's create a new Symfony project. I am gonna build a simple blog.
symfony new --full blog
This command will create a skeleton of the app. You should see the message like this one

Now we need a web server, but instead of installing Nginx or apache, we will use a simple built-in PHP webserver.
To run a web-server, open up a new terminal window and write the next line in your project directory.
symfony server:start
Or you can use this one:
php -S 127.0.0.1:8000
Now you have a web server up and running.

Open that link in the browser and you should see a welcome page.

First page
To create a page you need to create a route and a controller.
Let's start with the controller.
I want to create the main page for my blog, where I will show a list of blog posts.
Create a first controller
and index
action. Create a MainController.php
under src/Controller
folder:
<?php
namespace App\Controller;
class MainController
{
public function index()
{
return 'test';
}
}
Now you need to create a route for this action. We will use Symfony Annotations to add routes.
<?php
namespace App\Controller;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Response;
class MainController
{
/**
* @Route("/", name="index")
*/
public function index()
{
return new Response('test');
}
}
As you can see we added a new annotation directive @Route
. It's a special annotation that tells the framework to use index
method to process our main route.
Reload your page, you should see the word test
in your browser.
Congratulations, you have created your first action.
Database Schema, Entity
Before we move forward, let`s create a database schema for our blog. I will go with something simple. Our blog will have next fields:
- title
- short_description
- body
- image
I assume that you have MySQL client installed on your local machine.
The Symfony framework uses Doctrine ORM to talk to the database. It’s an abstraction layer between application and database client. I will not go into details here. But I will explain a bit about what we will do here.

Basically you create entities(models). You save them, and doctrine does all the rest for you - saving into a real database(MySQL in our case). You don't have to write native SQL queries, instead, you will be writing queries in DQL(Doctrine Query Language). Doctrine gives you a predefined set of methods from out of the box(it's a big plus).
So let's create our first entity. To do so, write the next command in your terminal:
php bin/console make:entity
You will see an interactive window. Follow the steps I described below.

- Name it
Blog
. - Now you need to add properties:
2.1title
, typestring
, length40
, nullable -no
2.2short_description
, typestring
, length40
, nullable -no
2.3body
, typetext
, nullable -no
2.4image
, typestring
, length100
, nullableyes
Now you should see two files that were created:
src/Entity/Blog.php
src/Repository/BlogRepository.php
If you messed up with something, don't worry. You can delete those files and do it again. If you still struggle, check this link. It's a full project, so you can find those files.
Ok great, we have our entity in place, but they before we move on we need to create an actual database schema.
Create a new MySQL database:

mysql -u {USER} -p{PASSWORD}
create database blog;
exit;
Replace {USER}
with your MySQL user name, and {PASSWORD}
with your user password.
Change your .env
file(located in the root directory of the project).
DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7
Change db_user
to your mysql user
and db_password
to your database password
, db_name
to blog
After that, populate schema by running the next command:
php bin/console doctrine:schema:create
Ok, we have created schema and entity. Now it's time to populate database with fake data(fake blog information).
For this purpose, there is a Symfony library called - FixtureBundle
Open up a terminal window and run next command:
composer require --dev orm-fixtures
This command will install the extra library and will create a file App\DataFixtures\AppFixtures
Open that file - src/DataFixtures/AppFixtures.php
and let's create a few blog posts.
<?php
namespace App\DataFixtures;
use App\Entity\Blog;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
for ($i = 0; $i < 4; ++$i) {
$blog = new Blog();
$blog->setTitle('Lorem ipsum');
$blog->setBody('Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Proin sodales, arcu non commodo vulputate, neque lectus luctus metus,
ac hendrerit mi erat eu ante. Nullam blandit arcu erat,
vitae pretium neque suscipit vitae.
Pellentesque sit amet lacus in metus placerat posuere. Aliquam hendrerit risus elit, non commodo nulla cursus id.
Vivamus tristique felis leo, vitae laoreet sapien eleifend vitae. Etiam varius sollicitudin tincidunt');
$blog->setShortDescription('Lorem ipsum description');
}
$manager->flush();
}
}
As you can see, we created four blog posts. ObjectManager
is a doctrine persistence layer. It's an object that we use to manipulate with our entities.
In the example above we used persist
and flush
methods. Persist tells to ObjectManager
that we can store this entity state to the database.
And flush
flushes all changes to objects and saves them into a database(MySQL in our case).
Run this command to load these new entities into a database:
php bin/console doctrine:fixtures:load
Cool, now let's create a base view with a list of our blogs.
Index action extended
Symfony uses a template engine called Twig.
I strongly recommend reading more about twig in the documentation.
To display an HTML page from a controller you need to create a template.
There is a base.html.twig
file that you can use as a base template.
Go to a root directory of the project and find a template
folder. Create a new file called list.html.twig
and copy/paste the next code.
{% extends 'base.html.twig' %}
{% block body %}
<h1>Blogs</h1>
{% endblock %}
This line means that we extend a base twig template and I overwrote a body block with a Blogs
heading.
So let's change our controller's index action. Go to your MainController
and extend it:
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Response;
class MainController extends AbstractController
We need to extend AbstractController
because we want to use additional methods from that controller.
Now, modify your index function:
return $this->render('list.html.twig');
Reload your page:

Before moving on to the next section and actually display all blogs. Let's add bootstrap CSS to our code. Add this code to the <head>
section of base.html.twig
file.
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
Blog list
It's time to show the list of blogs on your index page.
To do so we need to fetch these blogs from the database. So, we need to inject a BlogRepository
to our method.
/**
* @Route("/")
*
* @param BlogRepository $blogRepository
*
* @return Response
*/
public function index(BlogRepository $blogRepository)
{
return $this->render('list.html.twig');
}
What is BlogRepository? It's a class where you will be writing all the methods needed to fetch data from the database. And also this class has a predefined set of method which we can use.
How this thing(injections) works. This is a bit out of the scope of this tutorial. But in short, we use autowire dependency injection. The framework does all the dirty work for us.
In order to fetch all the blogs that we have, use a fetchAll
method. Let's modify our index action.
public function index(BlogRepository $blogRepository)
{
return $this->render('list.html.twig', ['blogs' => $blogRepository->findAll()]);
}
And change list.html.twig
file, add this line under h1
tag:
{{ dump(blogs) }}
This command is useful when you want to dump data into a readable format to debug it. If you run it, you will see something like this:

Let's bring it to a normal view. I will create a table to display a blog list.
{% extends 'base.html.twig' %}
{% block stylesheets %}
<style>
.align-center {
margin: 0 auto;
}
</style>
{% endblock %}
{% block body %}
<div class="row">
<div class="col-lg-8 align-center">
<h1>Blogs</h1>
<table class="table table-striped ">
<thead class="thead-dark">
<tr>
<th>Title</th>
<th>Short description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for blog in blogs %}
<tr>
<td>{{ blog.title }}</td>
<td>{{ blog.shortDescription }}</td>
<td>
<button class="btn btn-primary">Edit</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
As you can see I used a FOR loop and displayed titles and short descriptions. and displayed titles and short descriptions.
You can reload your page and you should see the list of blogs with edit action like shown on the image below.

Try to add an extra button, DELETE
action for each blog post.
Ok, we have a list of blogs in place, now we need a way to create a single blog post.
Create action
To create a new blog, we need to create a new action in our controller. Let's name it createBlog
.
use Symfony\Component\HttpFoundation\Request;
........
/**
* @param Request $request
*
* @return Response
*/
public function createBlog(Request $request)
{
return $this->render('create.html.twig');
}
Create a new template create.html.twig
{% extends 'base.html.twig' %}
{% block body %}
<div class="row">
<div class="col-lg-8 align-center">
<h1>Create Blog</h1>
</div>
</div>
{% block %}
To create a form in Symfony you need to create a FormType. Symfony has a powerful Form features. You don't have to worry about things like validation, form fields rendering, and more. I recommend to read official documentation and get familiar with forms.
So let's create our form type, create a file called BlogFormType.php
inside ./src/Form/Type
folder:
<?php
namespace App\Form\Type;
use App\Entity\Blog;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BlogFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('title', TextType::class, ['attr' => ['class' => 'form-control']]);
$builder->add('shortDescription', TextType::class, ['attr' => ['class' => 'form-control']]);
$builder->add('body', TextType::class, ['attr' => ['class' => 'form-control']]);
$builder->add('imageFile', FileType::class, [
'attr' => ['class' => 'form-control',],
'mapped' => false,
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'data_class' => Blog::class,
]
);
}
}
buildForm
- method is used to describe form fields, and form types. We added one unmapped field, this means that we do not save this imageFile
in the database, instead, we gonna save imageName
.
We set a Blog
class as a data class of our form. Thus, Symfony will know that it works with a Blog entity.
Now, let's render it, update the code in create.html.twig file
:
<div class="row">
<div class="col-lg-8 align-center">
<h1>Create Blog</h1>
{{ form_start(form) }}
{{ form_rest(form) }}
<input type="submit" class="btn btn-primary"/>
{{ form_end(form) }}
</div>
</div>
and createBlog
action inside MainController.php
:
/**
* @Route("/create")
*
* @param Request $request
*
* @return Response
*/
public function createBlog(Request $request)
{
$form = $this->createForm(BlogFormType::class, new Blog());
return $this->render('create.html.twig', [
'form' => $form->createView()
]);
}
As you can see, we created a form. We passed a new Blog entity object to this form, and created a form view and passed it our twig template.
Reload your page, you should see your form:

Let's update our form style. If you want to know how to customize forms and how to render them consider reading this documentation.
Update create.html.twig
file.
<div class="col-lg-8 align-center">
<h1>Create new blog entry.</h1>
{{ form_start(form) }}
<div class="form-group">
{{ form_widget(form.title) }}
</div>
<div class="form-group">
{{ form_widget(form.shortDescription) }}
</div>
<div class="form-group">
{{ form_widget(form.imageFile) }}
</div>
<div class="form-group">
{{ form_widget(form.body) }}
</div>
<input type="submit" class="btn btn-primary"/>
{{ form_end(form) }}
</div>

Now it looks much prettier. Let's update our create
action method to process our form submit:
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$blog = $form->getData();
$entityManager->persist($blog);
$entityManager->flush();
$this->addFlash('success', 'Blog was created!');
}
And add this code to create.html.twig
under <h1>
tag:
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class="flash-{{ label }}">
{{ message }}
</div>
{% endfor %}
{% endfor %}
So what have we done? In our controller, we added form validation and persisted our Blog post into a database.
In our template, we displayed a success message.
At this point, you can try to create a blog.
But what about the image? Yes, right we need to save the image somewhere on our computer. To do this we need to add more code. Let's update createBlog
action.
if ($form->isSubmitted() && $form->isValid()) {
$blog = $form->getData();
$imageFile = $form->get('image')->getData();
if ($imageFile) {
$originalFilename = pathinfo($imageFile->getClientOriginalName(), PATHINFO_FILENAME);
$safeFilename = $slugger->slug($originalFilename);
$newFilename = $safeFilename.'-'.uniqid().'.'.$imageFile->guessExtension();
try {
$imageFile->move(
$this->getParameter('image_directory'),
$newFilename
);
} catch (FileException $e) {
$this->addFlash('error', 'Image cannot be saved.');
}
$blog->setImage($newFilename);
}
$entityManager->persist($blog);
$entityManager->flush();
$this->addFlash('success', 'Blog was created!');
}
We added image processing. We check if an image exists in the request. If so, we change its name and move it to an image_directory, and then we save the file name of the image.
I forgot to mention that you have to add image_directory
parameter in
/config/services.yaml
under parameters
section:
parameters:
image_directory: '%kernel.project_dir%/public'
Try it out, you should be able to upload the image. You should see a newly created image under the public
directory.
Validation
Before we move on to edit action, we need to add validation to our form.
Why?
Ask yourself these questions:
- What if a user will upload a 100 megabytes pdf file?
- What if I inject HTML code into a title?
We have to handle such cases. For this purpose, Symfony has in-built Validation Constraints. I will show you how to use them.
We can add validation constraints with annotations. Let's edit our /Entity/Blog.php
class.
use Symfony\Component\Validator\Constraints as Assert;
.......
/**
* @ORM\Column(type="string", length=40)
* @Assert\NotBlank(message="Title cannot be empty.")
* @Assert\Length(max=40)
*/
private $title;
/**
* @ORM\Column(type="string", length=40)
* @Assert\NotBlank()
* @Assert\Length(max=40)
*/
private $short_description;
/**
* @ORM\Column(type="text")
* @Assert\NotBlank()
*/
private $body;
/**
* @ORM\Column(type="string", length=100, nullable=true)
* @Assert\Image()
*/
private $image;
We added assertations here. Our fields cannot be blank, and we asserted that the image should be an actual image. There are many more options for Image validation, but this is out of the scope of this tutorial. You can read about options and try them here.
Now you can try to create a blog. And try to upload some file(not image), you should see the error.
Improvements
So far, we created a list and createBlog
actions. We need to add one more thing. We need a create
button on this page. Find a list.html.twig
file and copy-paste this code(at the bottom of the body block):
<div class="row">
<div class="col-lg-8 align-center">
<a href="{{ path('app_main_createblog') }}" class="btn btn-primary">Create blog</a>
</div>
</div>
Path - is a special function, that you can use in a twig to generate routes. To find a route, use this command:
php /bin/console debug:route

It will show every route you have in your project and its name. I recommend installing Symfony plugin in your IDE of choice. I use PHPStorm, but if you are using VisualStudioCode or something else, search for Symfony plugin. With a plugin, you don’t need to debug the route. Route autocomplete will be available for you.
You can add a name to your route explicitly, like this(in this case you don't have to debug routes - recommended):
@Route("/", name="app_index")
Lastly, I want to redirect the user to the list page after creating the blog. Change this line in createBlog
action.
$this->addFlash('success', 'Blog was created!');
return $this->redirectToRoute('app_main_index');
And update list.html.twig
, insert this code under <h1>
tag:
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class="flash-{{ label }}">
{{ message }}
</div>
{% endfor %}
{% endfor %}
Ok, we're good.
Edit action
Edit blog is pretty similar to create action, but it's a bit different. We need to fetch the blog by id and populate the form with existing data from the database.
Create a new method in the controller called editBlog
/**
* @Route("/edit/{id}")
*
* @ParamConverter("blog", class="App:Blog")
*
* @return Response
*/
public function editBlog(Blog $blog, Request $request, EntityManagerInterface $entityManager, SluggerInterface $slugger)
{
$blog->setImage(new File(sprintf('%s/%s', $this->getParameter('image_directory'), $blog->getImage())));
$form = $this->createForm(BlogFormType::class, $blog);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$blog = $form->getData();
$imageFile = $form->get('imageFile')->getData();
if ($imageFile) {
$originalFilename = pathinfo($imageFile->getClientOriginalName(), PATHINFO_FILENAME);
$safeFilename = $slugger->slug($originalFilename);
$newFilename = $safeFilename.'-'.uniqid().'.'.$imageFile->guessExtension();
try {
$imageFile->move(
$this->getParameter('image_directory'),
$newFilename
);
} catch (FileException $e) {
$this->addFlash('error', 'Image cannot be saved.');
}
$blog->setImage($newFilename);
}
$entityManager->persist($blog);
$entityManager->flush();
$this->addFlash('success', 'Blog was edited!');
}
return $this->render('create.html.twig', [
'form' => $form->createView(),
]);
}
As you can see this method is almost the same as createAction
.
* @Route("/edit/{id}")
*
* @ParamConverter("blog", class="App:Blog")
{id}
is a wildcard, it's a blog id, @ParamConverter
annotation converts id into an Object, in our case, it's a Blog. Basically you can do it on your own using the repository method - find
.
Additionally, we need to update our BlogFormType
to avoid file processing(in case if there is no file present). Add a required => false
here:
$builder->add('imageFile', FileType::class, [
'attr' => ['class' => 'form-control',],
'mapped' => false,
'required' => false
]);
And one finishing touch, change edit
button URL to existing edit action. You have to update list.html.twig
file:
<td>
<a href="{{ path('app_main_editblog', {id: blog.id}) }}" class="btn btn-primary">Edit</a>
</td>
Try it out, you should be able to click on the edit button and it will redirect you to the edit form.
Let's move on and build a delete
action.
Delete blog
We need to add one more action to our MainController
:
/**
* @Route("/delete/{id}", name="app_blog_delete")
*
* @param Blog $blog
* @param EntityManagerInterface $em
*
* @return RedirectResponse
*/
public function deleteBlog(Blog $blog, EntityManagerInterface $em): RedirectResponse
{
$em->remove($blog);
$em->flush();
$this->addFlash('success', 'Blog was edited!');
return $this->redirectToRoute('app_main_index');
}
It's pretty simple, we remove blog with entityManager
and then we add a success message and redirect the user to the main page.
If you have not added the delete button yet, let's do it now. Go to list.html.twig
and add delete button, under the edit button.
<a href="{{ path('app_blog_delete', {id: blog.id}) }}" onclick="return confirm('Are you sure?');" class="btn btn-danger">DELETE</a>
That's it.
Conclusion
In this tutorial, you have learned how to create a simple CRUD application. From this point, I suggest learning more about doctrine, Symfony forms.
If you want to see the full project, you can find it here on GitHub.
If you have any questions, comment down below, I'll try to answer.
Thank you.