fix(posts): cache the link card as an array, not the DTO

The link-card cache stored a LinkCardMetadata object, which does not round-trip through the Redis cache driver — a cache hit came back as __PHP_Incomplete_Class and 500'd the preview endpoint. Cache the plain array (toArray) and rebuild the DTO via a new fromArray(). Primitives round-trip cleanly through every driver. The cache test now asserts a primitive is stored and a hit reconstructs the DTO; the array cache driver used in tests hid the bug because it never serializes.
This commit is contained in:
Paulo Castellano 2026-07-17 16:34:04 -03:00
parent 925fd85b95
commit 8866e4ed46
3 changed files with 35 additions and 4 deletions

View file

@ -32,11 +32,16 @@ public function fetch(string $text): ?LinkCardMetadata
return null;
}
return Cache::remember(
// Cache a plain array, never the DTO: a rich object does not round-trip
// cleanly through every cache driver (it comes back as an incomplete
// class), whereas primitives always do.
$data = Cache::remember(
'link_card:'.sha1($url),
now()->addMinutes(self::CACHE_MINUTES),
fn (): ?LinkCardMetadata => $this->build($url),
fn (): ?array => $this->build($url)?->toArray(),
);
return $data === null ? null : LinkCardMetadata::fromArray($data);
}
private function build(string $url): ?LinkCardMetadata

View file

@ -16,6 +16,22 @@ public function __construct(
public ?string $imageUrl,
) {}
/**
* Rebuild the DTO from its cached array form (the inverse of toArray, minus
* the derived `domain`, which toArray recomputes).
*
* @param array{uri?: string, title?: string, description?: string, image?: ?string} $data
*/
public static function fromArray(array $data): self
{
return new self(
uri: (string) data_get($data, 'uri', ''),
title: (string) data_get($data, 'title', ''),
description: (string) data_get($data, 'description', ''),
imageUrl: data_get($data, 'image'),
);
}
/**
* @return array{uri: string, domain: string, title: string, description: string, image: ?string}
*/

View file

@ -3,6 +3,8 @@
declare(strict_types=1);
use App\Services\Social\LinkCard\LinkCardFetcher;
use App\Services\Social\LinkCard\LinkCardMetadata;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
// example.com resolves to a public IP so the SafeHttpFetcher SSRF guard passes;
@ -50,8 +52,16 @@
]);
$fetcher = app(LinkCardFetcher::class);
$fetcher->fetch('https://example.com');
$fetcher->fetch('https://example.com');
$first = $fetcher->fetch('https://example.com');
$second = $fetcher->fetch('https://example.com');
Http::assertSentCount(1);
// The cache must hold a primitive array, not the DTO — a rich object does
// not round-trip through every cache driver (it comes back as an incomplete
// class). A cache hit still reconstructs a proper DTO.
expect(Cache::get('link_card:'.sha1('https://example.com')))->toBeArray();
expect($second)->toBeInstanceOf(LinkCardMetadata::class)
->and($second->uri)->toBe($first->uri)
->and($second->title)->toBe('Cached');
});