Skip to content

[9.x] Alternative Mailable Syntax - #44462

Merged
taylorotwell merged 23 commits into
9.xfrom
mailables
Oct 11, 2022
Merged

[9.x] Alternative Mailable Syntax#44462
taylorotwell merged 23 commits into
9.xfrom
mailables

Conversation

@taylorotwell

@taylorotwell taylorotwell commented Oct 4, 2022

Copy link
Copy Markdown
Member

Currently, mailables are "configured" via a build method. Within this method, various methods on the parent mailable class are invoked to specify the structure of the mailable:

public function build()
{
    return $this->from('example@example.com', 'Example')
                ->subject('Order Shipped')
                ->view('emails.orders.shipped');
}

While this works fine, over the last couple of years it has always rubbed me the wrong way. It feels a lot like an initialization method or a two-step constructor. The mailable object can't be inspected in any useful way until that method is invoked. There aren't really any other types of objects in Laravel user-land that work this way or follow this pattern.

This PR offers an alternative syntax for defining mailables in which several discrete methods may be defined that each return configuration style objects that describe the structure of the mailable:

<?php

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class InvoicePaid extends Mailable
{
    use Queueable, SerializesModels;

    /**
     * Create a new message instance.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Get the message envelope.
     *
     * @return \Illuminate\Mail\Mailables\Envelope
     */
    public function envelope()
    {
        return new Envelope(
            subject: 'Invoice Paid',
            cc: [new Address('foo@example.com', 'Example Name')],
            tags: [],
            metadata: [],
        );
    }

    /**
     * Get the message content definition.
     *
     * @return \Illuminate\Mail\Mailables\Content
     */
    public function content()
    {
        return new Content(
            view: 'html-view-name',
            text: 'text-view-name',
        );
    }

    /**
     * Get the attachments for the message.
     *
     * @return \Illuminate\Mail\Mailables\Attachment[]
     */
    public function attachments()
    {
        return [
            Attachment::fromPath('/path/to/file'),
        ];
    }
}

Note: Only the content method is really required when defining mailables in this fashion.

A more minimal example would look like the following:

<?php

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Queue\SerializesModels;

class InvoicePaid extends Mailable
{
    use Queueable, SerializesModels;

    /**
     * Get the message content definition.
     *
     * @return \Illuminate\Mail\Mailables\Content
     */
    public function content()
    {
        return new Content(
            markdown: 'invoice-paid',
        );
    }
}

The DX of this feature relies heavily on PHP 8 named arguments to provide a nice, clean experience when defining the configuration of the mailable object.

While the resulting mailable does end up having more lines of code overall, it feels a bit more like a real object and not a weird, two-step constructed thing. The envelope, content, and attachments methods may all be invoked as many times as needed and their results inspected like normal objects.

A few new methods have also been added to inspect mailable instances:

$mailable->hasSubject('Foo');
$mailable->hasTag('foo');
$mailable->hasMetadata('key', 'value');

In addition, an optional headers method may be defined to add additional headers, including arbitrary text headers to the message:

use Illuminate\Mail\Mailables\Headers;

public function headers()
{
    return new Headers(
        messageId: 'some-message-id@laravel.com',
        references: ['reference@laravel.com', 'another-reference@laravel.com'],
        text: [
            'X-Some-Header' => 'Some-Value',
        ],
    );
}

The traditional way of defining mailables using the build method will not be removed.

Comment thread src/Illuminate/Mail/Mailable.php
@Sammyjo20

Sammyjo20 commented Oct 6, 2022

Copy link
Copy Markdown
Contributor

I really like the syntax, but I have a suggestion to ?maybe? improve readability, of course you could write it with the constructor arguments, but what about methods too?

public function envelope()
{
    $envelope = new Envelope('Invoice Paid');
      
    // addRecipient ($address, $type = 'to')
    $envelope->addRecipient(new Address('foo@example.com', 'Example Name'), type: 'cc');
    $envelope->addRecipient(new Address('foo@example.com', 'Example Name'), type: 'bcc');
   
    $envelope->addTags([]);

    $envelope->addMetadata([]);

   return $envelope;
}

@Sammyjo20

Copy link
Copy Markdown
Contributor

In fact methods will be really helpful to conditionally apply logic like, if in production always CC the sales email address.

@adityakdevin

Copy link
Copy Markdown

Syntax looks great

@hailwood

hailwood commented Oct 6, 2022

Copy link
Copy Markdown
Contributor

In fact methods will be really helpful to conditionally apply logic like, if in production always CC the sales email address.

So perhaps as well as the methods we could add the Conditionable trait as well so we can use when/unless chaining?

@nunomaduro nunomaduro changed the title Alternative Mailable Syntax [9.x] Alternative Mailable Syntax Oct 6, 2022
@taylorotwell

Copy link
Copy Markdown
Member Author
cc: ['taylor@example.com'] + ($production ? ['second@example.com'] : []),

@hailwood

hailwood commented Oct 6, 2022

Copy link
Copy Markdown
Contributor
cc: ['taylor@example.com'] + ($production ? ['second@example.com'] : []),

Sure that's possible but it's not exactly nice to scan, especially if you're using the full address syntax?

return new Envelope(
    subject: 'Invoice Paid',
    cc: [new Address('taylor@example.com', 'Taylor Otwell')] + ($production ? [new Address('second@example.com', 'Second Address')] : []),
    tags: [],
    metadata: [],
);

I'd be more inclined to go

$ccRecipients = [new Address('taylor@example.com', 'Taylor Otwell')];

if($production) {
    $ccRecipients[] = new Address('second@example.com', 'Second Address');
}

return new Envelope(
    subject: 'Invoice Paid',
    cc: $ccRecipients,
    tags: [],
    metadata: [],
);

But isn't it easier to understand if it's just

$envelope = new Envelope(
    subject: 'Invoice Paid',
    cc: [new Address('taylor@example.com', 'Taylor Otwell')],
    tags: [],
    metadata: [],
));

if($production) {
    $envelope->addRecipient(new Address('second@example.com', 'Second Address'), type: 'cc');
}

return $envelope;

Or if conditionable then

return (new Envelope(
    subject: 'Invoice Paid',
    cc: [new Address('taylor@example.com', 'Taylor Otwell')],
    tags: [],
    metadata: [],
)))->when($production, function(Envelope $envelope) {
    $envelope->addRecipient(new Address('second@example.com', 'Second Address'), type: 'cc');
});

@timacdonald timacdonald left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggesting we explicitly mark constructors with named argument support.

This is a custom docblock, similar to what is seen in PHPUnit (however they are opting out of named argument support).

As Laravel does not support named arguments globally, this will serve as a marker for developers and maintainers that the feature is supported here.

Comment thread src/Illuminate/Mail/Mailables/Headers.php
Comment thread src/Illuminate/Mail/Mailables/Envelope.php
Comment thread src/Illuminate/Mail/Mailables/Content.php

@lioneaglesolutions lioneaglesolutions left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would indeed be nice to add the fluent methods - have suggested some changes including the tests.

See;

Comment thread src/Illuminate/Mail/Mailables/Envelope.php
Comment thread tests/Mail/MailableAlternativeSyntaxTest.php
@andrey-helldar

andrey-helldar commented Oct 7, 2022

Copy link
Copy Markdown
Contributor

For me personally, explicitly passing constructor properties is redundant and ugly.

So agree with @Sammyjo20 for adding methods.

It is also necessary to be able to use beautiful code. For example:

<?php

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class InvoicePaid extends Mailable
{
    use Queueable, SerializesModels;

    public function envelope()
    {
        return (new Envelope())
            ->setSubject('Invoice Paid')
            ->addRecipient(new Address('foo@example.com', 'Example Name'), type: 'cc')
            ->addRecipient(new Address('foo@example.com', 'Example Name'), type: 'bcc')
            ->addTags(['foo', 'bar'])
            ->addMetadata([]);
    }

    public function content()
    {
        return new Content(
            view: 'html-view-name',
            text: 'text-view-name',
        );
    }
}

@nathanjansen

Copy link
Copy Markdown

Just another possible syntax.

public function envelope()
{
    return new Envelope(
        subject: 'Invoice Paid',
        cc: Envelope\CC::make()
            ->add(new Address('foo@example.com', 'Example Name'))
            ->when($production, new Address('admin@example.com', 'Example Admin')),
        tags: [],
        metadata: [],
    );
}

@laravel laravel deleted a comment from garygreen Oct 7, 2022
Co-authored-by: Tim MacDonald <hello@timacdonald.me>
taylorotwell and others added 4 commits October 7, 2022 13:01
@taylorotwell

Copy link
Copy Markdown
Member Author

Added methods and conditionable trait to Envelope.

@Sammyjo20

Copy link
Copy Markdown
Contributor

Methods look good!

@taylorotwell

Copy link
Copy Markdown
Member Author

Documentation: laravel/docs#8282

@ghost

ghost commented Dec 8, 2022

Copy link
Copy Markdown

How can we use this syntax into APIs?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.