Twitter From Scratch Using Laravel: Email Verification with Test

In our last lesson we've learned about how to customize the Laravel's built-in authentication scaffolding. In this lesson we will be talking about email verification with test. We can also learn about Laravel's Mailables and how to test email in Laravel in the process.
First, in our
.env
file you'll notice a mail variables:
MAIL_DRIVER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
This is where we put our mail related credentials. As you can see it's default to SMTP and uses Mailtrap which we will cover in our future lessons.Next, let's add:
MAIL_FROM_ADDRESS=no-reply@twitter.com
MAIL_FROM_NAME=Twitter
This will be used in config/mail.php
as a global from
for our applicationDriver Prerequisites
In production it is recommended to use an API based driver such as Mailgun or SparkPost because it is often simpler and faster than SMTP. If you decide to use one you need to install the Guzzle HTTP library which can be installed via Composer package manager:composer require guzzlehttp/guzzle
Next, add your credentials to your
config/services.php
file:
'mailgun' => [
'domain' => env('MAILGUN_DOMAIN'),
'secret' => env('MAILGUN_KEY'),
],
'sparkpost' => [
'secret' => env('SPARKPOST_SECRET'),
],
Next, update your
MAIL_DRIVER
in your .env
file:
MAIL_DRIVER=mailgun
For testing purposes we're just gonna use log driver to make our test much faster, it means we're not gonna send an email to an email address, we're just gonna log it to a file. So let's override our mail driver for testing by adding
MAIL_DRIVER
config to our phpunit.xml
:
<env name="MAIL_DRIVER" value="log"/>
Next, let's create
MailTracking.php
under our tests
directory and grab Jeffrey Way's awesome email test assertions. You can watch the full video on how it's made in Laracasts for free.Next, let's auto load it for development only in our
composer.json
file:
"autoload-dev": {
"files": [
"tests/MailTracking.php"
]
}
Next, let's run:
composer dump-autoload
Next, let's create a feature test in our
tests/Feature/UserTest.php
that will make sure that an email is sent after registration:
class UserTest extends TestCase
{
use DatabaseMigrations, \MailTracking;
/** @test */
public function a_confirmation_email_must_be_sent_after_registration()
{
$email = 'johndoe@gmail.com';
$response = $this->post('/register', [
'first_name' => 'John',
'last_name' => 'Doe',
'email' => $email,
'password' => 'secret',
'password_confirmation' => 'secret'
]);
$this->seeEmailWasSent();
}
}
Notice that we're using a backslash in \MailTracking
because we're on a Tests\Feature
namespace.Next, let's run our test:
phpunit --filter a_confirmation_email_must_be_sent_after_registration
There was 1 failure:
1) Tests\Feature\UserTest::a_confirmation_email_must_be_sent_after_registration
No emails have been sent.
Failed asserting that an array is not empty.
As expected it failed.Let's fix that. First, let's listen for a
created
event in our User
model:
use App\Events\UserHasRegistered;
class User extends Authenticatable
{
/**
* The event map for the model.
*
* @var array
*/
protected $events = [
'created' => UserHasRegistered::class,
];
}
This means every time a new user is created that UserHasRegistered
event class will be fired.Next, let's add that to our
app/Providers/EventServiceProvider.php
with a listener:
class EventServiceProvider extends ServiceProvider
{
/**
* The event listener mappings for the application.
*
* @var array
*/
protected $listen = [
'App\Events\UserHasRegistered' => [
'App\Listeners\SendVerificationEmail',
],
];
}
Next let's run:
php artisan event:generate
This will automatically create those classes in the $listen
property if it's not yet existed.Next, let's add our
User
model instances to our UserHasRegistered
event class:
use App\User;
class UserHasRegistered
{
public $user;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct(User $user)
{
$this->user = $user;
}
}
That's all there is to it. Laravel will automatically inject those dependencies in the constructor for us.Next, let's create a
UserHasRegistered
mailable class with markdown:
php artisan make:mail UserHasRegistered --markdown=user.email.verification
This will create a UserHasRegistered.php
file under app/Mail
directory and a verification.blade.php
file under resources/views/user/email
directory.Next, let's work on
app/Mail/UserHasRegistered.php
:
use App\User;
class UserHasRegistered extends Mailable
{
use Queueable, SerializesModels;
/**
* The \App\User instance
*
* @var \App\User
*/
public $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(User $user)
{
$this->user = $user;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
return $this->markdown('user.email.verification');
}
}
Again, we just need to inject our dependencies in the constructor and assigned it to a public property and it will be automatically available in our views. Then in the build
method we have to use $this->markdown()
method instead of $this->view()
because we're gonna use markdown.Next, let's update our
resources/views/user/email/verification.blade.php
:
@component('mail::message')
# Final step...
Confirm your email address to complete your Twitter account {{ $user->username }}. It's easy — just click the button below.
@component('mail::button', ['url' => $user->email_verification_url])
Confirm now
@endcomponent
Thanks,
{{ config('app.name') }}
@endcomponent
Next, let's add an email verification url accessor in our
User
model:
/**
* Get the email verification url.
*
* @return string
*/
public function getEmailVerificationUrlAttribute()
{
return route('account.verify.email', ['token' => $this->email_verification_token]);
}
Next, let's add an
email_verfication_token
column in our users table.
$table->string('email_verification_token')->nullable();
It must be nullable because it indicates that the user already verified their email if the value is null.Next, let's create a route for our email verification url:
Route::group(['prefix' => 'account', 'namespace' => 'Account', 'as' => 'account.'], function () {
Route::group(['prefix' => 'verify', 'namespace' => 'Verification', 'as' => 'verify.'], function () {
Route::get('email/{token}', 'EmailController@verify')->name('email');
});
});
Route::group(['prefix' => '{username}', 'namespace' => 'User'], function () {
Route::resource('tweet', 'TweetController');
});
Notice we have a nested route group and an as
array key. This is to achieve our route name account.verify.email
. Also make sure that all of your routes must be above on a route that has a wild card in the first segment of the uri to avoid conflict.Next, let's go to
App\Listeners\SendVerificationEmail.php
where the actual sending of the email will happen, particularly in the handle
method.
use App\Events\UserHasRegistered;
use Illuminate\Support\Facades\Mail;
use App\Mail\UserHasRegistered as UserHasRegisteredMail;
class SendVerificationEmail
{
/**
* Create the event listener.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Handle the event.
*
* @param UserHasRegistered $event
* @return void
*/
public function handle(UserHasRegistered $event)
{
Mail::to($event->user->email)->send(new UserHasRegisteredMail($event->user));
}
}
Notice we just use the to
method in the Mail
facade and pass the user email, then chain the send
method and pass the new instance of UserHasRegistered
mailble class which in this case we have to alias it to UserHasRegisteredMail
because we already have a UserHasRegistered
class name for our event. This time our test should get greed.Next, let's add an assertion that our subject is correct:
$this->seeEmailSubject("Confirm your Twitter account, John");
Our test shoul failed this time:
1) Tests\Feature\UserTest::a_confirmation_email_must_be_sent_after_registration
No email with a subject of Confirm your Twitter account, John was found.
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'Confirm your Twitter account, John'
+'User Has Registered'
Let's fix that by adding a subject in our
UserHasRegistered
mailable class:
public function build()
{
return $this->subject("Confirm your Twitter account, {$this->user->first_name}")
->markdown('user.email.verification');
}
Next, let's assert that the email is sent to the correct email address:
$this->seeEmailTo('johndoe@gmail.com')
Next, let's assert that the email contains the correct verification url:
/** @test */
public function a_confirmation_email_must_be_sent_after_registration()
{
$email = 'johndoe@gmail.com';
$token = str_random(60);
$response = $this->post('/register', [
'first_name' => 'John',
'last_name' => 'Doe',
'email' => $email,
'password' => 'secret',
'password_confirmation' => 'secret',
'email_verification_token' => $token,
]);
$this->seeEmailWasSent()
->seeEmailSubject("Confirm your Twitter account, John")
->seeEmailTo('johndoe@gmail.com')
->seeEmailContains(route('account.verify.email', ['token' => $token]));
}
There was 1 failure:
1) Tests\Feature\UserTest::a_confirmation_email_must_be_sent_after_registration
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.email_verification_token
And our test failed.Let's fix that by adding our
email_verification_token
column to our fillable fields in our User
model:
protected $fillable = [
'first_name', 'last_name', 'email', 'password', 'email_verification_token',
];
Next, let's update our
app/Http/Controllers/Auth/RegisterController.php
:
protected function create(array $data)
{
return User::create([
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
'email_verification_token' => $data['email_verification_token'] ?? str_random(60)
]);
}
Notice we're using a new PHP7 feature called "Null coalescing operator", basically it's just a syntactic sugar for isset($data['email_verification_token']) ? $data['email_verification_token'] : str_random(60)
. Next, when we run our test again we should get green.Next, in the same test file let's create a test that will make sure our email verification url is working:
/** @test */
public function a_user_can_verify_their_email()
{
$user = factory('App\User')->create();
$this->get(route('account.verify.email', ['token' => $user->email_verification_token]));
$this->assertNull($user->fresh()->email_verification_token);
}
What we're doing here is first we create a user, then visit the email verification url, after that we assert that the email_verification_token
is null which indicates that it's verified. We also use the fresh()
method here to get a fresh data from the users table.Run our test again:
There was 1 error:
1) Tests\Feature\UserTest::a_user_can_verify_their_email
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.email_verification_token
It failed because we don't have an
email_verification_token
in our user model factory:
return [
'email_verification_token' => str_random(60),
];
Now, we get a different error:
There was 1 error:
1) Tests\Feature\UserTest::a_user_can_verify_their_email
ReflectionException: Method App\Http\Controllers\Account\Verification\EmailController::verify() does not exist
Let's fix that by adding a
verify
method in our EmailController
:
use App\User;
public function verify($token)
{
$user = User::whereEmailVerificationToken($token)->first();
if (is_null($user)) {
return redirect('/home')->with('message', 'The email confirmation link you followed has expired.Click "Resend confirmation" from Settings for a new one.');
}
$user->verifyEmail();
return redirect('/home')->with('message', 'Your account is now verified.');
}
What we're doing here is we fetch the user that corresponds with the token, then if it's null we redirect them to the home page and flashing data to the session. Otherwise, we call the verifyEmail()
method from our User
model.Next, let's add a
verifyEmail()
method in our User
model:
public function verifyEmail()
{
$this->email_verification_token = null;
$this->save();
return $this;
}
At this point our test should be back to green again.Lastly, let's add an alert box for now in our
layouts/app.blade.php
for the message:
@if (session()->has('message'))
{{ session('message') }}
@endif
@yield('content')
Now we're confident that our email verification feature is working perfectly because we have a test for it. That's all for this lesson, as always if you have any questions please write it down in the comment below and see you in the next lesson. Thanks!
View the source code for this lesson on GitHub.
Previous: Custom AuthenticationNext: Create Tweet
Post a Comment