Welcome to this extensive guide where we'll look at how to effectively create a newsletter subscription system in Laravel. Laravel is a robust and flexible PHP framework that is suitable for a wide range of applications, including the creation of a functional and customizable newsletter.
In today's era, newsletters are one of the most important tools for communication with customers or users of a website. Whether you provide updates, educational materials, or special offers, it is crucial that your newsletter subscription system is user-friendly and effective.
This guide will walk you through the entire process of creating such a system in Laravel, from the basic preparation of a database factory, through creating an email template and Livewire component, to routing settings. Last but not least, we will look at the importance of translations and internationalization, and how to achieve this in Laravel.
Of course, any development should be accompanied by quality testing, and so we will also focus on how to write effective tests using the Pest tool.
Let's get to it and find out how to create a great newsletter subscription system in Laravel!
Database Factory Preparation
For working with data in Laravel, one of the prerequisites is to prepare a database factory that will provide us with a framework for manipulating data and allow us to work efficiently and safely with our database.
In this guide, we will work with one specific model - NewsletterUser. This model will represent individual users who subscribe to our newsletter.
The first step is to create a factory for our model. Although this procedure is not typical, it will give you a better idea of what the model will contain. The factory will allow us to generate test data for our purposes. With it, we will be able to easily create instances of our NewsletterUser model with randomly generated data, which will come in handy during the final testing.
In Laravel, creating a factory is very simple. Just use the Artisan command make:factory, which automatically generates the factory skeleton for our model.
php artisan make:factory NewsletterUserFactory --model=NewsletterUser
This will create a new NewsletterUserFactory class that we will then modify for our needs. In our case, at least the following attributes will need to be set: email, language, verification_token, and is_verified.
The resulting class could look like this:
<?php
namespace Database\Factories\Newsletter;
use App\Enums\Newsletter\NewsletterLanguage;
use App\Models\Newsletter\NewsletterUser;
use Illuminate\Database\Eloquent\Factories\Factory;
class NewsletterUserFactory extends Factory
{
protected $model = NewsletterUser::class;
public function definition()
{
return [
'email' => $this->faker->email(),
'language' => NewsletterLanguage::ENGLISH,
'is_verified' => false,
];
}
}
This factory now allows us to create test instances of our NewsletterUser model with random data. This will come in handy during the further development and testing of our newsletter subscription system.
Database Migration
Migration is a key part of Laravel that allows us to create the structure of our database directly from our code. Let's create a migration using the artisan command:
php artisan make:migration create_newsletter_users_table
In our case, we created a migration for the NewsletterUser model, which contains all the necessary information for managing newsletter subscribers, such as email, language, and verification status.
<?php
use App\Enums\Newsletter\NewsletterLanguage;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('newsletter_users', function (Blueprint $table) {
$table->id();
$table->string('email', 128)->unique();
$table->string('language', 6)->default(NewsletterLanguage::ENGLISH->value);
$table->boolean('is_verified')->default(false);
$table->string('verification_token', 60)->nullable();
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('newsletter_users');
}
};
Thanks to migration, we can easily create, modify, and delete database tables, which is very useful during application development and distribution among different environments. Moreover, Laravel automatically keeps track of which migrations have already been applied, so we don't have to deal with their repeated execution.
Creating an Email Template
When a user enters their email and selects the preferred language of the newsletter, we need to send them a verification email. It contains a verification link that the user has to click on to verify their email. For this purpose, we will need to create an email template.
Laravel again offers very convenient work with email templates. We start by creating a new class for our email notification using the Artisan command:
php artisan make:mail NewsletterVerificationMail --markdown=emails.newsletter-verification
With this command, we created a new NewsletterVerificationMail class and at the same time told Laravel that we want to use markdown format to create our email template. Laravel automatically created a file for this template in the resources/views/emails directory.
We can now modify this template to suit our needs. In our case, for demonstration purposes, we need to display several pieces of information in the email:
- Welcome message
- Verification link
- Button to redirect to our website
The resulting template could look like this:
<x-mail::message>
# {{ __('template.newsletter-email-verification-headline') }}
{{ __('template.newsletter-email-verification-description') }}
<x-mail::button :url="$signedUrl">
{{ __('template.newsletter-email-verification-verify-button') }}
</x-mail::button>
<x-mail::button :url="$homepageUrl">
{{ __('template.newsletter-email-verification-homepage-button') }}
</x-mail::button>
{{ __('template.newsletter-email-verification-thanks') }},<br>
{{ config('app.name') }}
</x-mail::message>
In this template, we used Laravel's mail::message and mail::button components, which allow us to easily create the structure of our email. The texts are loaded from translation files, so we can easily switch between different languages according to the user's choice.
Finally, we need to use this template in our NewsletterVerificationMail class:
<?php
namespace App\Mail\App\Guest\Newsletter;
use App\Models\Newsletter\NewsletterUser;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class NewsletterVerificationMail extends Mailable
{
use Queueable, SerializesModels;
public NewsletterUser $newsletterUser;
public function __construct(NewsletterUser $newsletterUser)
{
$this->newsletterUser = $newsletterUser;
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: __('template.newsletter-email-verification-subject') . ' | PavelZanek.com',
);
}
/**
* Get the message content definition.
*
* @return \Illuminate\Mail\Mailables\Content
*/
public function content()
{
$signedUrl = URL::signedRoute('newsletter.verify-user', [
'language' => $this->newsletterUser->language,
'token' => $this->newsletterUser->verification_token
]);
return new Content(
markdown: 'emails.app.guest.newsletter.verification-mail',
with: [
'newsletterUser' => $this->newsletterUser,
'signedUrl' => $signedUrl,
'homepageUrl' => route('homepage', ['language' => 'en']),
],
);
}
}
There is no need to discuss the code sample in more detail here, we will use the provided data and set the subject of the email.
Creating a Livewire Component
Livewire is an excellent tool for creating dynamic components in Laravel, allowing for an easy connection between JavaScript and PHP. Its main advantage is that it does not require knowledge of JavaScript and everything can be solved directly in PHP.
In our case, we will use a Livewire component to create a form for newsletter subscription.
First, we create a new Livewire component using the Artisan command:
php artisan make:livewire NewSubscriberForm
This will create two new files - NewSubscriberForm.php and new-subscriber-form.blade.php. The first one is a class where we define the logic of the component. The second one is a template that describes what the component looks like.
In the NewSubscriberForm.php class, we define two public attributes - $email and $language, which represent the values entered by the user in our form. Furthermore, we need to create a subscribe() method, which will be called after the form submission.
<?php
namespace App\Http\Livewire\Guest\Newsletter;
use App\Actions\Guest\Newsletter\CreateNewsletterUserAction;
use App\Enums\Newsletter\NewsletterLanguage;
use App\Mail\App\Guest\Newsletter\NewsletterVerificationMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\Rules\Enum;
use Livewire\Component;
class NewSubscriberForm extends Component
{
public string $email = '';
public string $language = '';
public function render()
{
return view('livewire.guest.newsletter.subscription-form');
}
public function subscribe()
{
$validatedData = $this->validate([
'email' => 'required|email|max:128|unique:newsletter_users,email',
'language' => [
'required',
'string',
'max:6',
new Enum(NewsletterLanguage::class),
],
]);
$newsletterUser = (new CreateNewsletterUserAction())->execute($validatedData);
// Send verification email
Mail::to($this->email)
->locale($this->language)
->send(new NewsletterVerificationMail($newsletterUser));
$this->reset('email');
$this->dispatchBrowserEvent('alert',[
'type'=> 'success',
'message'=> __('template.newsletter-waiting-for-validation')
]);
}
}
In the subscribe() method, we check if the input data is valid. If not, Livewire will automatically display error messages in our template.
Finally, in the new-subscriber-form.blade.php file, we create our form template:
<div>
<div class="items-top mb-3 space-y-4 sm:flex sm:space-y-0 max-w-4xl">
<div class="relative w-full mr-1">
<label for="newsletter_email" class="hidden mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
{{ __('template.newsletter-email-label') }}
</label>
<div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"></path><path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"></path></svg>
</div>
<input wire:model.defer="email" class="block p-3 pl-10 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 sm:rounded-none sm:rounded-l-lg focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-white dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" placeholder="{{ __('template.newsletter-email-placeholder') }}" type="email" id="newsletter_email">
@error('email')
<p class='mt-2 text-sm text-red-600'>{{ $message }}</p>
@enderror
</div>
<div class="relative w-full md:w-80 mr-1">
<label for="newsletter_language" class="hidden mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
{{ __('template.newsletter-language-label') }}
</label>
<select wire:model.defer="language" id="newsletter_language" class="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg text-sm focus:ring-blue-500 focus:border-blue-500 block w-full p-3 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
@foreach (config('app.locales') as $lang => $language)
<option value="{{ $lang }}">{{ __('template.' . $lang) }}</option>
@endforeach
</select>
@error('language')
<p class='mt-2 text-sm text-red-600'>{{ $message }}</p>
@enderror
</div>
<div>
<button wire:click="subscribe()" wire:loading.attr="disabled" type="submit" class="py-3 px-5 w-full text-sm font-medium text-center text-white rounded-lg border cursor-pointer bg-primary-700 border-primary-600 sm:rounded-none sm:rounded-r-lg hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
{{ __('template.newsletter-subscribe-button') }}
</button>
</div>
</div>
</div>
This code defines a form for entering email and selecting language. We use the wire:model command here, which allows us for two-way database binding between PHP and HTML. We also use wire:submit.prevent="subscribe" to submit the form and call our subscribe() method.
And that's all we need to create our Livewire component. How to integrate it into the page and further steps can be found below.
Implementing Livewire Component into the Template
Using a Livewire component in your Laravel application is straightforward and easy. In your Blade templating file, you simply use the @livewire directive. This directive accepts as the first parameter the name of the Livewire component you want to insert, and as the second parameter, you can pass an array with the data you want to pass to the component.
<section class="mb-4 md:mb-8">
<div class="mx-auto">
<h2 class="mb-2 text-xl tracking-tight font-extrabold text-gray-900 sm:text-2xl dark:text-white">
{{ __('template.newsletter-headline') }}
</h2>
<p class="mx-auto mb-2 font-light text-gray-500 sm:text-xl dark:text-gray-400">
{{ __('template.newsletter-description') }}
</p>
@livewire('guest.newsletter.new-subscriber-form', ['language' => app()->getLocale()])
</div>
</section>
In the example guest.newsletter.new-subscriber-form is the name of our component and ['language' => app()->getLocale()] is the data we are passing to the component - specifically, the language the user is currently on. Thanks to this simple notation, we can easily use the component anywhere in your project.
Setting Up Routing
Routing is a key element of any web application. Laravel provides great support for defining routes for your applications.
In our case, we only need to define one route:
- A route for user email address verification.
We define the route in the routes/web.php file.
...
Route::group([
'prefix' => "{language}",
'where' => ['language' => '[a-zA-Z]{2}']
], function () {
Route::get('/newsletter/verify/{token}', [NewsletterUserController::class, 'verify'])
->name('newsletter.verify-user')
->middleware('signed');
});
...
Note: It would be possible to use 2 routes - for user email address verification and a second route directly for the Livewire component.
In our case, we use the so-called signed route, which allows us to create links that are secure against "forgery". This route leads to the verify method in our NewsletterVerificationController.
Translations and Internationalization
Laravel provides a powerful tool for internationalizing your applications. Thanks to this, you can easily create applications that support multiple languages. For our newsletter system, we will need several translations.
Translations in Laravel are organized into files located in the resources/lang directory. Each language has its own directory and contains files with translations.
In our case, we will need translations for two languages, Czech and English. So, we create two files:
- resources/lang/cs/template.php
- resources/lang/en/template.php
In these files, we define the translations we will use in our newsletter subscription system. Each file contains an array, where the key represents the translation identifier and the value is the translation itself.
Sample for Czech translation:
<?php
return [
...
'newsletter-headline' => "Přihlašte se k odběru novinek",
'newsletter-description' => "Přihlaste se k odběru novinek o AI, Laravelu, SEO a mnoho dalšího.",
'newsletter-email-label' => "Email",
'newsletter-email-placeholder' => "Zadejte svůj email",
'newsletter-language-label' => "Jazyk",
'newsletter-subscribe-button' => "Odebírat",
'newsletter-waiting-for-validation' => "Na váš e-mailový účet byl odeslán ověřovací odkaz.",
'newsletter-invalid-token' => "Neplatný token.",
'newsletter-successfuly-verified' => "Úspěšně ověřeno.",
'newsletter-email-verification-subject' => "Ověřte prosím svou emailovou adresu",
'newsletter-email-verification-headline' => "Vítejte v mém Newsletteru!",
'newsletter-email-verification-description' => "Děkujeme, že jste se přihlásili k odběru mého newsletteru. Kliknutím na tlačítko níže ověřte svou e-mailovou adresu a dokončete odebírání novinek.",
'newsletter-email-verification-verify-button' => "Ověřit email",
'newsletter-email-verification-homepage-button' => "Přejít na web",
'newsletter-email-verification-thanks' => "Děkuji",
...
];
Sample for English translation:
<?php
return [
...
'newsletter-headline' => "Sign up for newsletter",
'newsletter-description' => "Sign up for news on AI, Laravel, SEO and much more.",
'newsletter-email-label' => "Email address",
'newsletter-email-placeholder' => "Enter your email",
'newsletter-language-label' => "Language",
'newsletter-subscribe-button' => "Subscribe",
'newsletter-waiting-for-validation' => "A verification link has been sent to your email account.",
'newsletter-invalid-token' => "Invalid token.",
'newsletter-successfuly-verified' => "Successfully verified.",
'newsletter-email-verification-subject' => "Please verify your email address",
'newsletter-email-verification-headline' => "Welcome to my Newsletter!",
'newsletter-email-verification-description' => "Thank you for subscribing to my newsletter. Click the button below to verify your email address and complete your newsletter subscription.",
'newsletter-email-verification-verify-button' => "Verify email",
'newsletter-email-verification-homepage-button' => "Go to website",
'newsletter-email-verification-thanks' => "Thanks",
...
];
We can then use these translations in our templates or code using the __ helper - as already shown in the previous code parts.
In this way, we can add more languages to our application by simply adding more translation files. Laravel will automatically choose the right language according to the application settings (which is not the topic of this article).
System Testing Using Pest
Once we have a system for signing up users to the newsletter, it is important to make sure that all its parts are working properly. This is where automated tests come in.
Pest is a popular tool for testing in Laravel. Pest is an elegant wrapper over PHPUnit, which allows writing tests in a declarative style, which is often more readable and easier to understand.
Pest allows testing all parts of our application. We can test if our routes behave correctly, if the data is stored correctly in the database, or if the correct emails are sent.
To test our newsletter system, we will create two tests:
- A test for verifying the newsletter user.
- A test for subscribing new users using the Livewire component.
In these tests, we verify whether users can subscribe to the newsletter and verify whether verification emails are being sent and whether flash messages are displayed correctly.
Testing the Livewire component - NewSubscriberFormTest.php:
<?php
use App\Enums\Newsletter\NewsletterLanguage;
use App\Http\Livewire\Guest\Newsletter\NewSubscriberForm;
use App\Mail\App\Guest\Newsletter\NewsletterVerificationMail;
use App\Models\Newsletter\NewsletterUser;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
it('subscribes a new user to the newsletter and sends a verification email', function () {
Mail::fake();
Livewire::test(NewSubscriberForm::class)
->set('email', 'test@example.com')
->set('language', NewsletterLanguage::ENGLISH->value)
->call('subscribe')
->assertDispatchedBrowserEvent('alert');
expect(NewsletterUser::where('email', 'test@example.com')->first())
->email->toEqual('test@example.com')
->language->toEqual(NewsletterLanguage::ENGLISH)
->is_verified->toBeFalse();
Mail::assertSent(NewsletterVerificationMail::class, function ($mail) {
return $mail->newsletterUser->email === 'test@example.com' &&
$mail->newsletterUser->language === NewsletterLanguage::ENGLISH;
});
});
it('validates the new user subscription input data', function () {
Livewire::test(NewSubscriberForm::class)
->set('email', 'invalid-email')
->set('language', 'invalid-language')
->call('subscribe')
->assertHasErrors(['email', 'language']);
});
Testing the controller - NewsletterUserControllerTest.php:
<?php
use App\Models\Newsletter\NewsletterUser;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\URL;
it('verifies a newsletter user successfully and redirects to the homepage with a success flash message', function () {
$newsletterUser = NewsletterUser::factory()->create();
$response = $this->get(URL::signedRoute('newsletter.verify-user', [
'language' => $newsletterUser->language,
'token' => $newsletterUser->verification_token
]));
$response->assertStatus(302);
$response->assertRedirect(route('homepage', ['language' => $newsletterUser->language]));
$response->assertSessionHas('flashType', 'success');
$response->assertSessionHas('flashMessage', __('template.newsletter-successfuly-verified'));
$newsletterUser->refresh();
expect($newsletterUser->is_verified)->toBeTrue();
});
it('fails to verify a newsletter user for an invalid token and redirects to the homepage with an error flash message', function () {
$response = $this->get(URL::signedRoute('newsletter.verify-user', [
'language' => Config::get('translatable.fallback_locale'),
'token' => 'invalidtoken'
]));
$response->assertStatus(302);
$response->assertRedirect(route('homepage', ['language' => Config::get('translatable.fallback_locale')]));
$response->assertSessionHas('flashType', 'error');
$response->assertSessionHas('flashMessage', __('template.newsletter-invalid-token'));
expect(NewsletterUser::where('is_verified', true)->count())->toBe(0);
});
Pest provides us with a range of methods for asserting such as assertStatus, assertRedirect or for example assertSessionHas. To verify the status in the database, we can use the assertDatabaseHas method.
When writing tests, it is important to make sure that the tests cover all possible scenarios and that they are independent of each other. This means that each test should have its own isolated state and should not affect the results of other tests.
Working with Actions
Actions in Laravel help us keep our code organized and ensure that each operation is clearly defined. In our newsletter subscription system, we created two key actions - CreateNewsletterUserAction and VerifyNewsletterUserAction, which we did not discuss in the article.
CreateNewsletterUserAction is responsible for creating a new user in our newsletter database. This is where the email and language chosen by the user for receiving the news are recorded.
<?php
namespace App\Actions\Guest\Newsletter;
use App\Models\Newsletter\NewsletterUser;
class CreateNewsletterUserAction
{
public function execute(array $validatedData): NewsletterUser
{
return NewsletterUser::create($validatedData);
}
}
Then, VerifyNewsletterUserAction verifies the user based on the token received in the verification email. This action also sets the is_verified flag to true upon successful verification.
<?php
namespace App\Actions\Guest\Newsletter;
use App\Models\Newsletter\NewsletterUser;
class VerifyNewsletterUserAction
{
public function execute(NewsletterUser $newsletterUser): NewsletterUser
{
$newsletterUser->update([
'is_verified' => true,
'verification_token' => null
]);
return $newsletterUser;
}
}
These two actions demonstrate how we can effectively organize our operations in Laravel. They make it easier for us to scale our newsletter system in the future.
Using Enum for Language Settings
The NewsletterLanguage enumerator provides us with an elegant and safe way to manipulate language codes in our application. By using Enum, we are sure that we will only work with permitted values - in our case, en for English and cs for Czech.
Using Enum also increases the readability of the code and simplifies maintenance, as all possible values are centralized in one place.
<?php
namespace App\Enums\Newsletter;
use Illuminate\Support\Collection;
enum NewsletterLanguage: string
{
case ENGLISH = 'en';
case CZECH = 'cs';
public static function all(): Collection
{
return collect(NewsletterLanguage::cases())->map(
fn(NewsletterLanguage $code) => $code->details()
);
}
public function details(): array
{
return match($this)
{
self::ENGLISH => [
'name' => 'English',
'value' => 'en',
],
self::CZECH => [
'name' => 'Czech',
'value' => 'cs',
],
};
}
}
The all and details methods then allow us to easily obtain all available languages and their details, which is useful for example when filling in a language selection field in the form.
Homework
Although this is functional code that is fully sufficient for this example, I have modified a few files so that you can try some practical part during development.
- Use of enumerator in Livewire component
In the template at the Livewire component, I used iteration over an array stated in the configuration file, specifically in app.php - config('app.locales'). However, in production, it would be ideal to use enum.
Hint: Why do we have the all() method in the enumerator? Could it be used? - Handling of validation messages
The Livewire component is not completely translated. Laravel can output messages in English for error messages, but not in Czech.
Hint: Livewire has a nicely described documentation and with the use of Laravel documentation, this task should be easily solvable. - Creating a seeder
If you want to have a database filled with randomly generated data and you do not want to rely only on tests, you will definitely use a seeder. This also makes it easier for other developers to understand your application without the need to create data manually.
Hint: Since we have a factory for creating model instances, including an example of use in the test, nothing should cause you problems. - Send mail in the queue
So that the user does not wait until the function is executed (an email is sent), you can use processing via so-called queues for the email.
Hint: Laravel again has everything nicely summarized in the documentation. Personally, I recommend "implementing" ( 🙂 ) the use of a queue in the file for email logic (i.e., the only class that is not used in our demonstration, yet the file contains it - it is even added if you generate the file via the artisan command).
Conclusion
Creating a newsletter subscription system in Laravel may seem like a complex task. This article should show you that it is not as complicated as it might seem at first glance. Thanks to this guide, you had the opportunity to familiarize yourself with the whole process from basic preparation to the implementation of individual parts and testing.
Laravel is a powerful and flexible framework that allows you to create different types of web applications. Using Livewire, we can easily create dynamic components and thanks to Pest, we can effectively test our application and make sure everything works correctly.
It is also important to remember internationalization and prepare our application for multilingual users. Laravel provides great tools for working with translations and localization.
I hope this guide has helped you understand how to create a newsletter subscription system in Laravel and that it has inspired you for further projects. Remember, practical experience is the most important thing, so don't hesitate and start your own project!