Skip to content

Conversation

@stevebauman
Copy link
Member

@stevebauman stevebauman commented Sep 8, 2025

This PR adds the ability to anonymize JSON resources by using the existing Anonymizable interface and a new AnonymizedResource trait:

namespace App\Resources;

use DirectoryTree\Anonymize\Anonymizable;
use DirectoryTree\Anonymize\AnonymizedResource;

class UserResource extends JsonResource implements Anonymizable
{
    use AnonymizedResource;

    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
        ];
    }

    public function getAnonymizedAttributes(Generator $faker): array
    {
        return [
            'name' => $faker->name(),
            'email' => $faker->safeEmail(),
        ];
    }
}

This helps in circumstances where a model cannot be used, such as Eloquent Pivot models, since Laravel does not reliably associate existing models to pivot relation properties.

What I mean by "reliably" is that I discovered Laravel does not set the Pivot model's pivotParent or pivotRelated properties the same way when eager-loading on a collection of models vs a single model. This means when defining anonymized attributes on a Pivot model, we can't always get the key on the Metanote model which is required to know what kind of fake data to generate. Here's some test cases illustrating this issue:

Setup:

class CallRecordMetanote extends Pivot implements Anonymizable
{
    use Anonymized;

    public function getAnonymizedAttributes(Generator $faker): array
    {
        // "key" will always be `null` here:
        return match ($this->pivotRelated->key) {
            'caller_name' => ['value' => $faker->name()],
            'document_summary' => ['value' => $faker->text()],
            'caller_date_of_birth' => ['value' => $faker->date()],
            'caller_email_address' => ['value' => $faker->email()],
            'unlisted_suggested_name' => ['value' => $faker->name()],
            default => [],
        };
    }

Using pivotParent attribute on the Pivot model:

test('it loads metanotes correctly', function () {
    $callRecord = CallRecord::factory()
        ->callerName('John Doe')
        ->create();

    $pivot = $callRecord->metanotes->first()->pivot;

    // Passes. Laravel sets the above call record in the instance.
    expect($pivot->pivotParent->is($callRecord))->toBeTrue();

    $callRecord = CallRecord::query()
        ->with('metanotes')
        ->get()
        ->first();

    $pivot = $callRecord->metanotes->first()->pivot;

    // Fails. Laravel sets an empty `CallRecord` instance.
    expect($pivot->pivotParent->is($callRecord))->toBeTrue();
});

Using pivotRelated attribute on the Pivot model:

test('it loads metanotes correctly', function () {
    // Single model instance:
    $callRecord = CallRecord::factory()
        ->callerName('John Doe')
        ->create();

    $metanote = Metanote::firstWhere('key', 'caller_name');

    $pivot = $callRecord->metanotes->first()->pivot;

    // Fails. An empty `Metanote` instance is set.
    expect($pivot->pivotRelated->is($metanote))->toBeTrue();

    // Collection of model instances with eager-loading:
    $callRecord = CallRecord::query()
        ->with('metanotes')
        ->get()
        ->first();

    $pivot = $callRecord->metanotes->first()->pivot;

    // Fails. An empty `Metanote` instance is set.
    expect($pivot->pivotRelated->is($metanote))->toBeTrue();
});

*/
public function getAnonymizableSeed(): string
{
return get_class($this).':'.$this->getKey();
Copy link
Member Author

Choose a reason for hiding this comment

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

This works automatically with JsonResource's because Laravel proxies undefined method and property calls to the underlying resource -- which will most of the time be a Model instance.

In other cases where a model instance is not used (or the resource does not have a getKey method), they may override this method to return something else:

public function getAnonymizableKey(): string
{
    return '...';
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Clever! I tried this but totally forgot the resource will just proxy the method call 🤦

Comment on lines +45 to +51
/**
* Get the key for the anonymizable instance.
*/
public function getAnonymizableKey(): ?string
{
return $this->getKey();
}
Copy link
Member Author

@stevebauman stevebauman Sep 9, 2025

Choose a reason for hiding this comment

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

I've added this method to help prevent possible collisions so developers don't have to remember to provide a unique seed prefix (get_class($this)) and can instead just worry about the key itself when overriding.

Copy link
Collaborator

@J0sh0nat0r J0sh0nat0r left a comment

Choose a reason for hiding this comment

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

Clean implementation! LGTM 👍

@stevebauman stevebauman merged commit 4640c22 into master Sep 9, 2025
4 checks passed
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.

3 participants