Single-Responsibility Principle: What is it?

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

SOLID starts with the Single-Responsibility Principle or SRP. SRP says:

A module should have only one reason to change.

Um, huh? This sounds... a little too "fluffy" to be actually useful.

Let's... try again with a... somewhat simpler definition:

A function or class should be responsible for only one task... or should have only one "responsibility".

Better. But... what is a "responsibility" exactly? And why is this rule helpful?

SRP: The Human Definition

On an even simpler level, what SRP is really trying to say is:

Gather together the things that change for the same reason and separate things that change for different reasons.

We'll talk more about this definition later, but keep it in mind.

And what problem is SRP trying to help us solve? In theory, if we organize our code into units that all change for the same reason, then when we get a new feature or change request, we will only need to modify one class... instead of making 10 changes to 10 different files... and trying not to break things along the way.

Sending a Confirmation Email

Enough defining stuff! Let's jump into an example. On your browser, click "Sign Up". As you can see, our app has a registration form! Open src/Controller/RegistrationController.php to see the code behind this. Most of the logic for saving the user is in this UserManager::register() method. Hold Cmd or Ctrl to jump into this: it lives at src/Manager/UserManager.php.

... lines 1 - 8
class UserManager
{
... lines 11 - 19
public function register(User $user, string $plainPassword): void
{
$user->setPassword(
$this->passwordEncoder->encodePassword($user, $plainPassword)
);
$this->entityManager->persist($user);
$this->entityManager->flush();
}
}

This method hashes the user's password... and then saves the user to the database. Awesome!

But now... we've received a change request! The product manager of Sasquatch Sightings - a suspiciously hairy person - would like us to send a confirmation email after registration to verify the user's email address.

To understand SRP, let's implement this the wrong way first. Well "wrong" according to SRP.

Side note: we're going to build a simple email confirmation system by hand. If you have this need in a real project, check out symfonycasts/verify-email-bundle.

Coding up the Confirmation Email System

Anyways, the easiest way I can see to add this feature is to add the logic right inside UserManager::register()... because we will only have to touch one file and it will guarantee that anything that calls this method will definitely trigger the confirmation email.

At the bottom of this class, I'm going to start by pasting in a private function called createToken(). You can copy this from the code block on this page. This generates a random string that we will include in the confirmation link.

... lines 1 - 8
class UserManager
{
... lines 11 - 29
private function createToken(): string
{
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
}
}

Up in register, generate a new token $token = $this->createToken()... and then set it on the user: $user->setConfirmationToken($token).

... lines 1 - 19
public function register(User $user, string $plainPassword): void
{
$token = $this->createToken();
$user->setConfirmationToken($token);
... lines 24 - 30
}
... lines 32 - 38

Before I started recording - if you look at the User.php file - I already created a $confirmationToken property that saves to the database. So thanks to the new code, when a user registers, they will now have a random confirmation token saved onto their row in the database.

... lines 1 - 15
class User implements UserInterface
{
... lines 18 - 60
/**
* @ORM\Column(type="string", unique=true, nullable=true)
*/
private $confirmationToken;
... lines 65 - 223
}

Back in RegistrationController... if you scroll down a bit, I've also already built a confirmation action to confirm their email. A user just needs to go to this pre-made route - where the {token} in the URL matches the confirmationToken that we've set onto their User record - and... bam! They'll be verified!

... lines 1 - 13
class RegistrationController extends AbstractController
{
... lines 16 - 43
/**
* @Route("/confirm/{token}", name="check_confirmation_link")
*/
public function confirmAction(string $token, UserRepository $userRepository, EntityManagerInterface $entityManager)
{
$user = $userRepository->findOneBy(['confirmationToken' => $token]);
if (!$user) {
throw $this->createNotFoundException(sprintf('The user with confirmation token "%s" does not exist', $token));
}
$user->setConfirmationToken(null);
$entityManager->flush();
$this->addFlash('success', 'Your email is confirmed! Let\'s go confirm some Bigfoot!');
return $this->redirectToRoute('app_homepage');
}
}

So back in UserManager, we have two jobs left. First, we need to generate an absolute URL to the confirmAction that contains their token. And second, we need to send an email to the user with that URL inside.

Let's generate the URL first. Up in the constructor, autowire RouterInterface $router. I'll hit Alt + Enter and go to "Initialize properties" to create that property and set it.

... lines 1 - 7
use Symfony\Component\Routing\RouterInterface;
... lines 9 - 10
class UserManager
{
... lines 13 - 14
private RouterInterface $router;
... line 16
public function __construct(UserPasswordEncoderInterface $passwordEncoder, EntityManagerInterface $entityManager, RouterInterface $router)
{
... lines 19 - 20
$this->router = $router;
}
... lines 23 - 44
}

Now, below, say $confirmationLink = $this->router->generate() and... the name of our route... is check_confirmation_link. Use that. For the second argument, pass token set to $user->getConfirmationToken(). And because this URL will go into an email, it needs to be absolute. Pass a third argument to trigger that: UrlGeneratorInterface::ABSOLUTE_URL.

... lines 1 - 23
public function register(User $user, string $plainPassword): void
{
... lines 26 - 28
$confirmationLink = $this->router->generate('check_confirmation_link', [
'token' => $user->getConfirmationToken()
], UrlGeneratorInterface::ABSOLUTE_URL);
... lines 32 - 38
}
... lines 40 - 46

Now, let's send the email! On top, add one more argument - MailerInterface $mailer and use the same Alt + Enter, "Initialize properties", trick to create that property and set it.

... lines 1 - 6
use Symfony\Component\Mailer\MailerInterface;
... lines 8 - 11
class UserManager
{
... lines 14 - 16
private MailerInterface $mailer;
... line 18
public function __construct(UserPasswordEncoderInterface $passwordEncoder, EntityManagerInterface $entityManager, RouterInterface $router, MailerInterface $mailer)
{
... lines 21 - 23
$this->mailer = $mailer;
}
... lines 26 - 47
}

Beautiful! Below, I'll paste in some email generation code. I'll also re-type the l on TemplatedEmail and hit tab so that PhpStorm adds the use statement on top for me.

... lines 1 - 6
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
... lines 8 - 12
class UserManager
{
... lines 15 - 27
public function register(User $user, string $plainPassword): void
{
... lines 30 - 36
$confirmationEmail = (new TemplatedEmail())
->from('staff@example.com')
->to($user->getEmail())
->subject('Confirm your account')
->htmlTemplate('emails/registration_confirmation.html.twig')
->context([
'confirmationLink' => $confirmationLink
]);
... lines 45 - 51
}
... lines 53 - 57
}

This creates an email to this user, from this address... and the template it references already exists. You can see it in: templates/emails/registration_confirmation.html.twig.

{% apply inline_css %}
<!doctype html>
<html lang="en">
... lines 4 - 42
<body>
<div class="body">
... lines 45 - 50
<div class="content">
<h1 class="text-center">Nice to meet you %name%!</h1>
<p class="block">
Please <a href="{{ confirmationLink }}">Confirm your account</a>.
</p>
<p class="block">
Or go directly to this URL: {{ confirmationLink }}
</p>
</div>
... lines 60 - 66
</div>
</body>
</html>
{% endapply %}

We're passing a confirmationLink variable... and that is rendered inside the email.

Finally, all the way at the bottom of register()... so after we know that the user has saved successfully, deliver the mail with: $this->mailer->send($confirmationEmail).

... lines 1 - 27
public function register(User $user, string $plainPassword): void
{
... lines 30 - 52
$this->mailer->send($confirmationEmail);
}
... lines 55 - 61

Alright! We did it! And we can even try this! Back at the registration page, register as a new user... any password, hit enter and... awesome! It looks like it worked!

Now, the project is not configured to actually deliver the email. But we can see what that imaginary email would have looked like by going down to the web debug toolbar, clicking any of these links to go to the profiler... hitting "last 10"... then clicking to get into the profiler for the POST request that we just made to the registration form.

On the left, click into the "Email" section. There's our email! You can even look at its HTML. I'm going to steal the confirmation link... pop it into a new tab and... our email is confirmed! Mission accomplished!

And, all of our code is centralized into one method. But... we did just violate SRP: our UserManager class now has too many responsibilities! But what do I mean by the word "responsibility"? And what are the responsibilities that this class has? And what's the problem with violating SRP anyways? And does the influence of gravity extend out forever?

Let's answers most of these questions next.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4 || ^8.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.1
        "doctrine/annotations": "^1.0", // 1.12.1
        "doctrine/doctrine-bundle": "^2", // 2.3.1
        "doctrine/doctrine-migrations-bundle": "^3", // 3.1.1
        "doctrine/orm": "^2", // 2.8.4
        "knplabs/knp-time-bundle": "^1.15", // v1.16.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^6.0", // v6.1.2
        "symfony/console": "5.2.*", // v5.2.6
        "symfony/dotenv": "5.2.*", // v5.2.4
        "symfony/flex": "^1.9", // v1.12.2
        "symfony/form": "5.2.*", // v5.2.6
        "symfony/framework-bundle": "5.2.*", // v5.2.6
        "symfony/http-client": "5.2.*", // v5.2.6
        "symfony/mailer": "5.2.*", // v5.2.6
        "symfony/property-access": "5.2.*", // v5.2.4
        "symfony/property-info": "5.2.*", // v5.2.4
        "symfony/security-bundle": "5.2.*", // v5.2.6
        "symfony/serializer": "5.2.*", // v5.2.4
        "symfony/twig-bundle": "5.2.*", // v5.2.4
        "symfony/validator": "5.2.*", // v5.2.6
        "symfony/webpack-encore-bundle": "^1.6", // v1.11.1
        "symfony/yaml": "5.2.*", // v5.2.5
        "twig/cssinliner-extra": "^3.3", // v3.3.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.0
        "twig/twig": "^2.12|^3.0" // v3.3.0
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.2", // 3.4.0
        "fakerphp/faker": "^1.13", // v1.14.1
        "symfony/debug-bundle": "^5.2", // v5.2.4
        "symfony/maker-bundle": "^1.13", // v1.30.2
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/stopwatch": "^5.2", // v5.2.4
        "symfony/var-dumper": "^5.2", // v5.2.6
        "symfony/web-profiler-bundle": "^5.2" // v5.2.6
    }
}