Search code examples
phpdtophp-attributes

Custom validator for email doesn't throw exception when validating an invalid email address on a Spatie DTO object


I'm currently working on an integration with a third party API for an application, for which I'm using Spatie's data transfer object library. I'm currently looking to set up validation for some fields, and have run into an issue.

I've written the following validator attribute, to validate that a string contains an email address:

<?php

declare(strict_types=1);

namespace App\Http\Integrations\Foo\Data\Validators;

use Attribute;
use Spatie\DataTransferObject\Validation\ValidationResult;
use Spatie\DataTransferObject\Validator;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
final class ValidEmail implements Validator
{
    public function validate(mixed $value): ValidationResult
    {
        if (filter_var($value, FILTER_VALIDATE_EMAIL)) {
            return ValidationResult::valid();
        }
        return ValidationResult::invalid("Value should be a valid email address");
    }
}

And it's used in this DTO implementation:

<?php

declare(strict_types=1);

namespace App\Http\Integrations\Foo\Data;

use App\Http\Integrations\Foo\Data\Validators\ValidEmail;
use Carbon\CarbonImmutable;

/**
 * DTO class for a child in the birthday request results
 *
 * @psalm-immutable
 */
final class ChildBirthday extends DTO
{
    #[ValidEmail]
    public readonly string $email;

    public readonly CarbonImmutable $dob;

    public readonly bool $purchased;

    public function __construct(string $email, string $dob, bool $purchased)
    {
        $this->email = $email;
        $this->dob = CarbonImmutable::parse($dob);
        $this->purchased = $purchased;
    }

    public static function fromResponseItem(string $email, string $dob, bool $purchased): self
    {
        return new static($email, $dob, $purchased);
    }

    public function __toString(): string
    {
        return $this->email;
    }
}

NB: The DTO class extends my own bespoke DTO base class, but that is relatively simple - it just extends the Spatie one to apply strict mode, set a default cast for CarbonImmutable and set the psalm-immutable and psalm-seal-properties annotations.

I've also written a Pest test for it:

<?php

declare(strict_types=1);

namespace Tests\Feature\Http\Integrations\Foo\Data;

use App\Http\Integrations\Foo\Data\ChildBirthday;
use Carbon\CarbonImmutable;
use Spatie\DataTransferObject\Exceptions\ValidationException;

use function Pest\Faker\faker;

it('throws an error if the email is not valid', function () {
    ChildBirthday::fromResponseItem(
        email: 'invalid',
        dob: faker()->date(),
        purchased: faker()->boolean()
    );
})->throws(ValidationException::class);

However, the test fails to throw the required exception. Instantiating the class manually with an invalid email address also doesn't throw the exception.

My validation attribute looks broadly consistent with the example given in the documentation, but this is actually the first time I've used PHP 8 attributes, so I'm wondering if there's something I've got wrong about the syntax. I've tried setting a breakpoint in the attribute with PsySh and it never triggers when populating a new instance of the DTO in Laravel Tinker, so it looks like it's never calling the attribute in the first place.


Solution

  • Found the problem. It's due to my setting a constructor in the DTO class - it's fine to set a named constructor as I have done, but not the __construct() method. Removing the constructor method resolves the issue.