TL;DR: Symfony is a PHP framework as well as a set of reusable PHP components and libraries. It uses the Model-View-Controller design pattern and can be scaled to be used in any requirement. It aims to speed up the creation and maintenance of web applications, replacing repetitive code. In this part of the article, we will cover installing Bootstrap, a UI framework for web applications, to make the blog engine look nicer visually. The final code can be found at this repository.


Symfony Tutorial: About Part 1 and Part 2

In the first article, we:

  • installed and configured a Symfony installation;
  • created two new database tables: author and blog_post;
  • allowed users to authenticate with Auth0;
  • and ensured that the authenticated users have Author instances associated before using the system.

In this part of the article, we will cover installing Bootstrap, a UI framework for web applications, to make the blog engine look nicer visually. We will also enhance our blog engine to allow visitors to:

  • see a list of blog posts;
  • read a specific blog post;
  • and find out more about authors.

Besides that, authenticated authors will be able to:

  • create a new blog post;
  • see all of their own blog posts;
  • and delete their own blog posts from the system.

Building the Blog Engine

Before Starting

Make sure you have followed all instructions in the first part. However, if for some reason you lost the code created in the first part, or if you want to start here, feel free to clone this GitHub repository. The following commands will set up the application for you:

git clone https://github.com/auth0-blog/symfony-blog-part-1
cd symfony-blog-part-1

Install our dependencies with the following command:

composer install

In the root directory is a file called .env. Update the following values with your Auth0 credentials:

AUTH0_CLIENT_ID={AUTH0_CLIENT_ID}
AUTH0_CLIENT_SECRET={AUTH0_CLIENT_SECRET}
AUTH0_DOMAIN={AUTH0_DOMAIN}

Note that you will have to replace the values above. Check the first part to understand how to replace them.

Pro Tip! If you do not have a MySQL database available, an easy way to bootstrap one is with Docker:

docker run --name symfony-blog-mysql \
    -p 3306:3306 \
    -e MYSQL_ROOT_PASSWORD=myextremellysecretpassword \
    -e MYSQL_DATABASE=symfony-blog \
    -e MYSQL_USER=symfony-blog-user \
    -e MYSQL_PASSWORD=mysecretpassword \
    -d mysql:5.7

Now that you have a database with some credentials, in your .env file, find:

###> doctrine/doctrine-bundle ###
# Format described at http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# For an SQLite database, use: "sqlite:///%kernel.project_dir%/var/data.db"
# Configure your db driver and server_version in config/packages/doctrine.yaml
DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name
###< doctrine/doctrine-bundle ###

And change the following line: DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name to be correct. If you were to use the example details in the creation of the docker database, it would look like:

DATABASE_URL=mysql://symfony-blog-user:mysecretpassword@127.0.0.1:3306/symfony-blog

Lastly, if you haven't followed the first part of this series, you might need to issue the following commands to create the database tables and to populate them:

php bin/console doctrine:database:create
php bin/console doctrine:schema:update --force
php bin/console doctrine:fixtures:load

Running the following command, and then opening the URL in your browser, you should see how we left off in the first article.

php bin/console server:run

Installing Bootstrap

In order to install Bootstrap, we need Symfony's Webpack Encore, which is a simpler way to integrate Webpack into your application.

NOTE If you do not have Yarn, a Javascript package manager, installed, you will need to install and configure this first. So go to their Installation page and follow the instructions for installing and configuring Yarn first.

You can install Symfony's Webpack Encore by running the following command:

composer require webpack-encore

In the root directory of the project, there will be 2 new files (package.json, webpack.config.js) and a new directory (assets).

Open webpack.config.js, this is just the file that contains all of the web pack configurations, and replace the contents with:

var Encore = require('@symfony/webpack-encore');

Encore
    .setOutputPath('public/build/')
    .setPublicPath('/build')
    .cleanupOutputBeforeBuild()
    .addEntry('app', './assets/js/main.js')
    .addStyleEntry('global', './assets/css/global.scss')
    .enableSassLoader()
    .autoProvidejQuery()
    .enableSourceMaps(!Encore.isProduction());

module.exports = Encore.getWebpackConfig();

The configuration above uses two assets: main.js and global.scss. So in the directory assets in the project root, let's create two subdirectories: js and css. Inside these subdirectories, let's create the main.js and global.scss files. We will populate them further in the tutorial. After that, we will have the following substructure:

  • ./assets/js/main.js
  • ./assets/css/global.scss

Before we can compile our JavaScript and SCSS files to be used in our Symfony templates, we will need to install some dependencies. sass-loader and node-sass are libraries that load SASS/SCSS files and compile them to CSS. Let's install these dependencies by issuing the following codes:

yarn add sass-loader node-sass --dev

Now, we can compile our Javascript and CSS into assets to be used by Symfony by running this command, yarn run encore dev --watch.

Open the base twig template (which can be found at ./templates/base.html.twig) and replace the contents with:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8" />
    <title>{% block title %}Welcome!{% endblock %}</title>
    {% block stylesheets %}
        <link rel="stylesheet" href="{{ asset('build/global.css') }}" />
    {% endblock %}
</head>
<body>
{% block body %}{% endblock %}
{% block javascripts %}
    <script src="{{ asset('build/app.js') }}"></script>
{% endblock %}
</body>
</html>

We've now included both compiled CSS and empty Javascript files into the base template to be used throughout our app!

In order to make use of Bootstrap we need to install jQuery, so run the command:

yarn add jquery --dev

Once installed, at the top of the empty ./assets/js/main.js file, insert var $ = require('jquery');.

We want an app CSS asset file. Let's create the following file assets/css/main.scss. Then, in assets/js/main.js at the bottom paste the following: require('../css/main.scss');.

Let's move the contents of the CSS file we created in part 1 (public/css/style.css) into the new file we've created above (assets/css/main.scss).

Finally, in our base.html.twig, in the Stylesheets block, paste the following: <link rel="stylesheet" href="{{ asset('build/app.css') }}">.

Now we need to install bootstrap-sass with the following command: yarn add bootstrap-sass --dev. We need to import this into our Sass file. So in ./assets/css/global.scss, let's insert the following lines:

$brand-primary: darken(#428bca, 20%);
$icon-font-path: "~bootstrap-sass/assets/fonts/bootstrap/";
@import '~bootstrap-sass/assets/stylesheets/bootstrap';

Finally in your main.js file you need to require bootstrap-sass under your var $ = require('jquery'); line:

require('bootstrap-sass');

You've now set up Bootstrap to be used in your Symfony Blog.

"Integrating Webpack, Symfony, and Bootstrap is really easy!"

Showing Blog Posts

In ./src/Controller/BlogController.php, we need to make use the entity manager and the repositories for the entities in order to retrieve database data. At the top of the BlogController class, we want to inject these services.

/** @var EntityManagerInterface */
private $entityManager;

/** @var \Doctrine\Common\Persistence\ObjectRepository */
private $authorRepository;

/** @var \Doctrine\Common\Persistence\ObjectRepository */
private $blogPostRepository;

/**
 * @param EntityManagerInterface $entityManager
 */
public function __construct(EntityManagerInterface $entityManager)
{
    $this->entityManager = $entityManager;
    $this->blogPostRepository = $entityManager->getRepository('App:BlogPost');
    $this->authorRepository = $entityManager->getRepository('App:Author');
}

As you can see there is a class declared here so we need to add it to the namespaces at the top of the file. Where it says:

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;

Add: use Doctrine\ORM\EntityManagerInterface; above the two.

Great, in our entire controller we can call the blogPostRepository, authorRepository or entityManager when needed. The first two are used for retrieving data from the database, whereas the third will be used for inserting, updating, or deleting data (it can also be used for retrieving, but by setting up the construct this way, we will be reducing duplicate code).

Then replace the action below:

/**
 * @Route("/", name="homepage")
 */
public function indexAction()
{
    return $this->render('blog/index.html.twig', [
        'controller_name' => 'BlogController',
    ]);
}

with:

/**
 * @Route("/", name="homepage")
 * @Route("/entries", name="entries")
 */
public function entriesAction()
{
    return $this->render('blog/entries.html.twig', [
        'blogPosts' => $this->blogPostRepository->findAll()
    ]);
}

You now need to create a new template for this action, in ./templates/blog create a new file called entries.html.twig and paste the following in:

{% extends "base.html.twig" %}

{% block title %}App:blog:entries{% endblock %}

{% block body %}

{% endblock %}

Creating Blog Posts

Now that we have made a member restricted area of the site, let's allow the authenticated users to create a new blog post.

Create a new file called ./src/Form/EntryFormType.php and paste in the following:

<?php

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;

class EntryFormType extends AbstractType
{
    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add(
                'title',
                TextType::class,
                [
                    'constraints' => [new NotBlank()],
                    'attr' => ['class' => 'form-control']
                ]
            )
            ->add(
                'slug',
                TextType::class,
                [
                    'constraints' => [new NotBlank()],
                    'attr' => ['class' => 'form-control']
                ]
            )
            ->add(
                'description',
                TextareaType::class,
                [
                    'constraints' => [new NotBlank()],
                    'attr' => ['class' => 'form-control']
                ]
            )
            ->add(
                'body',
                TextareaType::class,
                [
                    'constraints' => [new NotBlank()],
                    'attr' => ['class' => 'form-control']
                ]
            )
            ->add(
                'create',
                SubmitType::class,
                [
                    'attr' => ['class' => 'form-control btn-primary pull-right'],
                    'label' => 'Create!'
                ]
            );
    }

    /**
     * {@inheritdoc}
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => 'App\Entity\BlogPost'
        ]);
    }

    /**
     * {@inheritdoc}
     */
    public function getName()
    {
        return 'author_form';
    }
}

As you can see, the data class to be used is the entity BlogPost, and if you compare the fields in buildForm you'll notice the first argument of each, the name, matches the names of the properties in BlogPost entity.

A new controller method is needed, so add this function to your AdminController class:

/**
 * @Route("/create-entry", name="admin_create_entry")
 *
 * @param Request $request
 *
 * @return \Symfony\Component\HttpFoundation\Response
 */
public function createEntryAction(Request $request)
{
    $blogPost = new BlogPost();

    $author = $this->authorRepository->findOneByUsername($this->getUser()->getUserName());
    $blogPost->setAuthor($author);

    $form = $this->createForm(EntryFormType::class, $blogPost);
    $form->handleRequest($request);

    // Check is valid
    if ($form->isSubmitted() && $form->isValid()) {
        $this->entityManager->persist($blogPost);
        $this->entityManager->flush($blogPost);

        $this->addFlash('success', 'Congratulations! Your post is created');

        return $this->redirectToRoute('admin_entries');
    }

    return $this->render('admin/entry_form.html.twig', [
        'form' => $form->createView()
    ]);
}

At the top, in the namespaces, we need to add the two new classes we're using: BlogPost and EntryFormType so paste:

use App\Entity\BlogPost;
use App\Form\EntryFormType;

We now need the template, so create a new file called ./templates/admin/entry_form.html.twig and insert the following code into it:

{% extends 'base.html.twig' %}

{% block title %}{% endblock %}

{% block body %}
    <div class="container">
        <div class="blog-header">
            <h2 class="blog-title"></h2>
        </div>

        <div class="row">
            <div class="col-sm-12 blog-main">
                {% for label, messages in app.flashes %}
                    {% for message in messages %}
                        <div class="bg-{{ label }}">
                            {{ message }}
                        </div>
                    {% endfor %}
                {% endfor %}

                {{ form_start(form) }}
                    <div class="col-md-12">
                        <div class="form-group col-md-4">
                            {{ form_row(form.title) }}
                        </div>
                        <div class="form-group col-md-4">
                            {{ form_row(form.slug) }}
                        </div>
                        <div class="form-group col-md-12">
                            {{ form_row(form.description) }}
                        </div>
                        <div class="form-group col-md-12">
                            {{ form_row(form.body) }}
                        </div>
                        <div class="form-group col-md-4 pull-right">
                            {{ form_widget(form.create) }}
                        </div>
                    </div>
                {{ form_end(form) }}
            </div>
        </div>
    </div>
{% endblock %}

Before we try to create a new entry, let's build the page that displays all of the authenticated users blog posts.

Displaying Blog Posts Created by Authenticated Author

In your AdminController, we need to add a route on the controller itself. Because there will be conflicts of routes between the two controllers. So above class AdminController extends Controller add:

/**
 * @Route("/admin")
 */

Also, find:

/**
 * @Route("/admin/author/create", name="author_create")
 */

and change the route to:

/**
 * @Route("/author/create", name="author_create")
 */

Now let's add a new method called entriesAction() and input the code below. All this will do is retrieve all of the blog posts by the authenticated user and pass those into the soon to be created template entries.html.twig to be displayed.

/**
 * @Route("/", name="admin_index")
 * @Route("/entries", name="admin_entries")
 *
 * @return \Symfony\Component\HttpFoundation\Response
 */
public function entriesAction()
{
    $author = $this->authorRepository->findOneByUsername($this->getUser()->getUserName());

    $blogPosts = [];

    if ($author) {
        $blogPosts = $this->blogPostRepository->findByAuthor($author);
    }

    return $this->render('admin/entries.html.twig', [
        'blogPosts' => $blogPosts
    ]);
}

Time to create the template ./templates/admin/entries.html.twig to store the following code in:

{% extends 'base.html.twig' %}

{% block title %}{% endblock %}

{% block body %}
    <div class="container">
        <div class="blog-header">
            <h1 class="blog-title">Author admin</h1>
            <p class="lead blog-description"></p>
        </div>

        <div class="row">
            <div class="col-md-12 col-lg-12 col-xl-12">
                {% for label, messages in app.flashes %}
                    {% for message in messages %}
                        <div class="alert alert-{{ label }}" role="alert">
                            <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
                            {{ message }}
                        </div>
                    {% endfor %}
                {% endfor %}
            </div>
            <div class="col-md-12 col-lg-12 col-xl-12">
                <a type="button" href="{{ path('admin_create_entry') }}" class="btn btn-primary pull-right">Add Entry</a>
            </div>
            <div class="col-sm-12 blog-main">
                <table class="table table-striped">
                    <thead>
                        <th>Title</th>
                        <th>Created At</th>
                        <th>Updated At</th>
                    </thead>
                    {% for blogPost in blogPosts %}
                        <tr>
                            <td>{{ blogPost.title }}</td>
                            <td>{{ blogPost.createdAt|date('F j, Y') }}</td>
                            <td>{{ blogPost.updatedAt|date('F j, Y') }}</td>
                        </tr>
                    {% else %}
                        <tr>
                            <td colspan="5">No entries available</td>
                        </tr>
                    {% endfor %}
                </table>
            </div>
        </div>
    </div>
{% endblock %}

As you can see in this template, there is a button to "Add entry" which will direct the user to create a new entry when clicked.

Creating Delete Functionality for Author's Posts

Our next step is to delete the authenticated users blog posts on demand. So in AdminController create a new method with the following code:

/**
 * @Route("/delete-entry/{entryId}", name="admin_delete_entry")
 *
 * @param $entryId
 *
 * @return \Symfony\Component\HttpFoundation\RedirectResponse
 */
public function deleteEntryAction($entryId)
{
    $blogPost = $this->blogPostRepository->findOneById($entryId);
    $author = $this->authorRepository->findOneByUsername($this->getUser()->getUserName());

    if (!$blogPost || $author !== $blogPost->getAuthor()) {
        $this->addFlash('error', 'Unable to remove entry!');

        return $this->redirectToRoute('admin_entries');
    }

    $this->entityManager->remove($blogPost);
    $this->entityManager->flush();

    $this->addFlash('success', 'Entry was deleted!');

    return $this->redirectToRoute('admin_entries');
}

This will check if the entryId passed in exists, check to ensure the authenticated user is the author of the article and then delete it.

There is no template needed for this, however, we still need somewhere in the templates to show the action. So in ./templates/admin/entries.html.twig let's make some additions.

In the table headers, let's add a new column. This will this element change from:

<thead>
    <th>Title</th>
    <th>Created At</th>
    <th>Updated At</th>
</thead>

to:

<thead>
    <th>Title</th>
    <th>Created At</th>
    <th>Updated At</th>
    <th>Action</th>
</thead>

And in the for loop, add a new td, which will contain the delete button. We will change it from:

<td>{{ blogPost.title }}</td>
<td>{{ blogPost.createdAt|date('F j, Y') }}</td>
<td>{{ blogPost.updatedAt|date('F j, Y') }}</td>

to:

<td>{{ blogPost.title }}</td>
<td>{{ blogPost.createdAt|date('F j, Y') }}</td>
<td>{{ blogPost.updatedAt|date('F j, Y') }}</td>
<td><a class="btn btn-danger" href="{{ path('admin_delete_entry', {'entryId': blogPost.id}) }}">Delete</a></td>

Add Pagination to Blog Posts List

We don't want to be loading all blog posts into the page, so let's add some pagination.

In the ./src/Controller/BlogController.php find entriesAction() and within the empty brackets type in: Request $request

At the top of the controller we need to include this class so where it shows:

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Doctrine\ORM\EntityManagerInterface;

Paste the following below those:

use Symfony\Component\HttpFoundation\Request;

Request allows you to gather data, for example, if there are any parameters in a POST or GET request.

At the top of our BlogController class, let's add a blog post limit constant:

/** @var integer */
const POST_LIMIT = 5;

We will want to default the page number to 1, but then make use of the object $request to find out if the user is on a different page number. So in the top of your entriesAction place the following:

$page = 1;

if ($request->get('page')) {
    $page = $request->get('page');
}

This data is useless to us unless BlogPostRepository knows what to do with it. When we created the BlogPost entity, it also created a repository for us. This repository contains our methods for custom queries or more in-depth queries. So open: ./src/Repository/BlogPostRepository.php

The first method we're going to need is to get all of the posts based on the page number and the limit previously set in the BlogController. The method below does exactly that. It retrieves the paginated number of blog posts.

/**
 * @param int $page
 * @param int $limit
 *
 * @return array
 */
public function getAllPosts($page = 1, $limit = 5)
{
    $entityManager = $this->getEntityManager();
    $queryBuilder = $entityManager->createQueryBuilder();
    $queryBuilder
        ->select('bp')
        ->from('App:BlogPost', 'bp')
        ->orderBy('bp.id', 'DESC')
        ->setFirstResult($limit * ($page - 1))
        ->setMaxResults($limit);

    return $queryBuilder->getQuery()->getResult();
}

We're going to need another method in order to get the count of blog posts so that our pagination can know whether you're on the last page or not. So below the previously added method, add another:

/**
 * @return array
 */
public function getPostCount()
{
    $entityManager = $this->getEntityManager();
    $queryBuilder = $entityManager->createQueryBuilder();
    $queryBuilder
        ->select('count(bp)')
        ->from('App:BlogPost', 'bp');

    return $queryBuilder->getQuery()->getSingleScalarResult();
}

Back in the entriesAction in your BlogController we're going to need to make use of those methods.

Your return previously looked like this:

return $this->render('blog/entries.html.twig', [
    'blogPosts' => $this->blogPostRepository->findAll()
]);

Let's change it to call those methods and pass the data as well as the page number and post limit into the template:

return $this->render('blog/entries.html.twig', [
    'blogPosts' => $this->blogPostRepository->getAllPosts($page, self::POST_LIMIT),
    'totalBlogPosts' => $this->blogPostRepository->getPostCount(),
    'page' => $page,
    'entryLimit' => self::POST_LIMIT
]);

Time to show your blog posts in a template file. Open ./templates/blog/entries.html.twig then change the title to whatever you wish (I changed it to "Blog Posts"). Inside {% block body %}, paste the following code:

<div class="container">
    <div class="blog-header">
        <h1 class="blog-title">Blog tutorial</h1>
        <p class="lead blog-description">A basic description of the blog, built in Symfony, styled in Bootstrap 3, secured by <a href="http://auth0.com">Auth0</a>.</p>
    </div>

    <div class="row">
        <div class="col-sm-8 blog-main">
            {% for blogPost in blogPosts %}
                {% set paragraphs = blogPost.description|split('</p>') %}
                {% set firstParagraph = paragraphs|first ~ '</p>' %}
                <div class="blog-post">
                    <h2 class="blog-post-title">
                        {{ blogPost.title }}
                    </h2>
                    <p class="blog-post-meta">
                        {{ blogPost.getUpdatedAt|date('F j, Y') }} by

                        {% if blogPost.author %}
                            {{ blogPost.author.name }}
                        {% else %}
                            Unknown Author
                        {% endif %}
                    </p>
                    {{ firstParagraph|raw }}<br />
                </div>
            {% else %}
                <div class="alert alert-danger" role="alert">
                    <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
                    <span class="sr-only">Error:</span>
                    You have no blog articles. Please log in and create an article.
                </div>
            {% endfor %}

            {% set canPrevious = page > 1 %}
            {% set canNext = (page * entryLimit) < totalBlogPosts %}
            <nav>
                <ul class="pager">
                    <li class="previous {% if canPrevious == false %}disabled{% endif %}">
                        <a href="{% if canPrevious %}{{ path('entries', {'page': page - 1}) }}{% endif %}">
                            <span aria-hidden="true">&larr;</span> Older
                        </a>
                    </li>
                    <li class="next {% if canNext == false %}disabled{% endif %}">
                        <a href="{% if canNext %}{{ path('entries', {'page': page + 1}) }}{% endif %}">
                            Newer <span aria-hidden="true">&rarr;</span>
                        </a>
                    </li>
                </ul>
            </nav>
        </div>
    </div>
</div>

Now reload your browser, you'll see the previously shown blog post, and below that, you'll see disabled "Previous" and "Next" buttons, they're disabled because you're on page 1, and there is only 1 blog post.

Quick tip, to start your application you can use php bin/console server:run

Adding Navigation

We need to enable users to find their way through the blog. So time to add some navigation. Create a new file in ./templates/nav_bar.html.twig and paste the following in:

<nav class="navbar navbar-default navbar-fixed-top">
    <div id="navbar" class="collapse navbar-collapse pull-right">
        <ul class="nav navbar-nav">
            {% if app.request.get('_route') not in ['homepage', 'entries'] %}
                <li><a href="{{ path("homepage") }}">Home</a></li>
            {% endif %}
            {% if app.user %}
                <li><a href="{{ path("admin_index") }}">Admin</a></li>
                <li><a href="{{ logout_url("secured_area") }}">Logout</a></li>
            {% else %}
                <li class="active"><a href="/connect/auth0">Login</a></li>
            {% endif %}
        </ul>
    </div>
</nav>

Then include the file into the ./templates/base.html.twig just below the <body> opening tag:

{% include 'nav_bar.html.twig' %}

Showing Specific Blog Post

In the BlogController class, create a new action to display the details of a specific blog post. Paste the following code into your controller:

/**
 * @Route("/entry/{slug}", name="entry")
 */
public function entryAction($slug)
{
    $blogPost = $this->blogPostRepository->findOneBySlug($slug);

    if (!$blogPost) {
        $this->addFlash('error', 'Unable to find entry!');

        return $this->redirectToRoute('entries');
    }

    return $this->render('blog/entry.html.twig', array(
        'blogPost' => $blogPost
    ));
}

In this action, you may have noticed that it checks if the blog post exists, if it doesn't it will set a "flash" session with an error message and redirect the user to the entries page to display the error.

Now, we need to output this data in the template. So create ./templates/blog/entry.html.twig and paste the following content:

{% extends "base.html.twig" %}

{% block title %}App:blog:entry{% endblock %}

{% block body %}
<div class="container">
 <div class="blog-header">
     <h1 class="blog-title">Blog tutorial</h1>
     <p class="lead blog-description">A basic description of the blog, built in Symfony, styled in Bootstrap 3, secured by Auth0.</p>
 </div>

 <div class="row">
     <div class="col-sm-8 blog-main">
         <div class="blog-post">
             <h2 class="blog-post-title">{{ blogPost.title }}</h2>
             <p class="blog-post-meta">
                {{ blogPost.updatedAt|date('F j, Y') }} by <a href="{{ path('author', {'name': blogPost.author.username|url_encode }) }}">
                    {{ blogPost.author.name }}
                </a>
            </p>
             <h3>Description:</h3>
             <p>{{ blogPost.description|raw }}</p>

             <h3>Body:</h3>
             <p>{{ blogPost.body|raw }}</p>
         </div>
     </div>
 </div>
</div>
{% endblock %}

The above code simply outputs the details of the blog post, its title, when it was updated at, the author, description, and the body.

Our next problem is how to access this new page we've created. Back in your ./templates/blog/entries.html.twig template, find {{ firstParagraph|raw }}<br /> and paste below:

<a href="{{ path('entry', {'slug': blogPost.slug}) }}">Read more</a>

Then you need to find {{ blogPost.title }} and wrap this in <a> tags so it will look like:

<a href="{{ path('entry', {'slug': blogPost.slug}) }}">
    {{ blogPost.title }}
</a>

Showing Author Details

Want to see more details about the author of the post? Let's create an authorAction in your BlogController. We're going to be doing something very similar to retrieving the single entry. We'll be getting the name passed in via the URL, finding the author by name in the database. And then passing that data into the author template, as shown below:

/**
 * @Route("/author/{name}", name="author")
 */
public function authorAction($name)
{
    $author = $this->authorRepository->findOneByUsername($name);

    if (!$author) {
        $this->addFlash('error', 'Unable to find author!');
        return $this->redirectToRoute('entries');
    }

    return $this->render('blog/author.html.twig', [
        'author' => $author
    ]);
}

Create the template ./templates/blog/author.html.twig, we won't be doing anything special, just outputting the data the Author has:

{% extends "base.html.twig" %}

{% block title %}App:blog:author{% endblock %}

{% block body %}
<div class="container">
    <div class="blog-header">
        <h1 class="blog-title">Author</h1>
        <p class="lead blog-description">A brief look into the author.</p>
    </div>

    <div class="row">
        <div class="col-sm-8 blog-main">
            <div class="blog-post">
                <h2 class="blog-post-title">{{ author.name|raw }}</h2>

                <p>Title: {{ author.title|raw }}</p>
                <p>Company: {{ author.company|raw }}</p>
                <p>Short Biography: {{ author.shortBio|raw }}</p>
                <ul>
                    {% if author.getPhone %}
                        <li>{{ author.phone }}</li>
                    {% endif %}
                    {% if author.getFacebook %}
                        <li>{{ author.facebook }}</li>
                    {% endif %}
                    {% if author.getTwitter %}
                        <li>{{ author.twitter }}</li>
                    {% endif %}
                    {% if author.getGithub %}
                        <li>{{ author.github }}</li>
                    {% endif %}
                </ul>

            </div>
        </div>
    </div>
</div>
{% endblock %}

We now need to have that author page linkable for people to access it. In: ./templates/blog/entries.html.twig you will find:

{% if blogPost.author %}
    {{ blogPost.author.name }}
{% else %}

Let's make this a link as shown below:

{% if blogPost.author %}
    <a href="{{ path('author', {'name': blogPost.author.username|url_encode }) }}">
        {{ blogPost.author.name }}
    </a>
{% else %}

Now refresh your browser. You'll see the title has changed to a link, and there is now a "Read more" at the bottom of your article. Click one of those and you'll see your new page! You will also see the author names have a link now.

"Urray! I've just finished creating my own blog engine with Symfony and PHP!"

Conclusion

Congratulations, you have just built yourself a functional blog engine from scratch with Symfony. This blog engine even enables visitors to sign up to become authors. This allows them to also contribute to your blog by posting articles of their own! Although this is just the basics of a blog, it is a strong stepping stone to making it as custom and feature filled as you wish.