Cache integration API reads and refresh them on a schedule
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 28s
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 28s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
8bee8d42c7
commit
2fe4fbd91e
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\EtsyConnection;
|
||||||
|
use App\Services\Bambuddy;
|
||||||
|
use App\Services\DryerClient;
|
||||||
|
use App\Services\Etsy;
|
||||||
|
use App\Services\IntegrationCache;
|
||||||
|
use App\Services\Parcel2Go;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[Signature('integrations:refresh')]
|
||||||
|
#[Description('Refresh cached Etsy, Parcel2Go, BamBuddy and dryer data so dashboard pages load instantly')]
|
||||||
|
class RefreshIntegrationCache extends Command
|
||||||
|
{
|
||||||
|
public function handle(IntegrationCache $cache, Etsy $etsy, Parcel2Go $parcel2Go, Bambuddy $bambuddy, DryerClient $dryer): int
|
||||||
|
{
|
||||||
|
$cache->forceRefresh = true;
|
||||||
|
|
||||||
|
if (($connection = EtsyConnection::first()) && $etsy->configured()) {
|
||||||
|
$this->refresh('etsy receipts', fn () => $etsy->receipts($connection));
|
||||||
|
$this->refresh('etsy listings', fn () => $etsy->listings($connection));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->refresh('parcel2go', function () use ($parcel2Go): void {
|
||||||
|
$parcel2Go->verifyCredentials();
|
||||||
|
foreach (['me/orders/detail', 'me/detail', 'me/prepay/balance'] as $path) {
|
||||||
|
$this->refresh('parcel2go '.$path, fn () => $parcel2Go->fetch($path));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($bambuddy->configured()) {
|
||||||
|
$this->refresh('bambuddy printers', fn () => $bambuddy->printers());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryer->configured()) {
|
||||||
|
$this->refresh('dryer status', fn () => $dryer->status());
|
||||||
|
$this->refresh('dryer history', fn () => $dryer->history());
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refresh(string $label, Closure $fetch): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$fetch();
|
||||||
|
$this->line($label.': refreshed');
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->warn($label.': '.$exception->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,7 +92,7 @@ class ShippingController extends Controller
|
|||||||
'height' => ['required', 'numeric', 'gt:0', 'lte:200'],
|
'height' => ['required', 'numeric', 'gt:0', 'lte:200'],
|
||||||
]);
|
]);
|
||||||
try {
|
try {
|
||||||
$receipt = collect($this->pendingOrders($etsy))->first(fn (array $order): bool => (string) ($order['receipt_id'] ?? '') === (string) $details['receipt_id']);
|
$receipt = collect($this->pendingOrders($etsy, true))->first(fn (array $order): bool => (string) ($order['receipt_id'] ?? '') === (string) $details['receipt_id']);
|
||||||
if (! $receipt) {
|
if (! $receipt) {
|
||||||
return redirect()->route('shipping')->withErrors(['quote' => 'This Etsy order is no longer pending. Refresh the order list.']);
|
return redirect()->route('shipping')->withErrors(['quote' => 'This Etsy order is no longer pending. Refresh the order list.']);
|
||||||
}
|
}
|
||||||
@@ -184,7 +184,7 @@ class ShippingController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$receipt = collect($this->pendingOrders($etsy))->first(fn (array $order): bool => (string) ($order['receipt_id'] ?? '') === (string) $request->session()->get('shipping_receipt_id'));
|
$receipt = collect($this->pendingOrders($etsy, true))->first(fn (array $order): bool => (string) ($order['receipt_id'] ?? '') === (string) $request->session()->get('shipping_receipt_id'));
|
||||||
if (! $receipt || ($receipt['zip'] ?? null) !== $details['delivery_postcode']) {
|
if (! $receipt || ($receipt['zip'] ?? null) !== $details['delivery_postcode']) {
|
||||||
return redirect()->route('shipping')->withErrors(['collection' => 'The Etsy order or delivery postcode changed. Request a fresh quote.']);
|
return redirect()->route('shipping')->withErrors(['collection' => 'The Etsy order or delivery postcode changed. Request a fresh quote.']);
|
||||||
}
|
}
|
||||||
@@ -236,10 +236,10 @@ class ShippingController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<int, array<string, mixed>> */
|
/** @return array<int, array<string, mixed>> */
|
||||||
private function pendingOrders(Etsy $etsy): array
|
private function pendingOrders(Etsy $etsy, bool $fresh = false): array
|
||||||
{
|
{
|
||||||
$connection = EtsyConnection::first();
|
$connection = EtsyConnection::first();
|
||||||
|
|
||||||
return $connection && $etsy->configured() ? ($etsy->receipts($connection)['results'] ?? []) : [];
|
return $connection && $etsy->configured() ? ($etsy->receipts($connection, $fresh)['results'] ?? []) : [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Services\IntegrationCache;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@@ -11,7 +12,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
//
|
$this->app->singleton(IntegrationCache::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use Throwable;
|
|||||||
|
|
||||||
class Bambuddy
|
class Bambuddy
|
||||||
{
|
{
|
||||||
public function __construct(private IntegrationSettings $settings) {}
|
public function __construct(private IntegrationSettings $settings, private IntegrationCache $cache) {}
|
||||||
|
|
||||||
public function url(): string
|
public function url(): string
|
||||||
{
|
{
|
||||||
@@ -26,6 +26,11 @@ class Bambuddy
|
|||||||
throw new RuntimeException('BamBuddy API key is not configured.');
|
throw new RuntimeException('BamBuddy API key is not configured.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->cache->remember('bambuddy.printers.'.md5($this->url()), 45, fn (): array => $this->fetchPrinters());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchPrinters(): array
|
||||||
|
{
|
||||||
$url = rtrim($this->url(), '/').'/api/v1/printers';
|
$url = rtrim($this->url(), '/').'/api/v1/printers';
|
||||||
$apiKey = $this->settings->for('bambuddy')['api_key'];
|
$apiKey = $this->settings->for('bambuddy')['api_key'];
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use RuntimeException;
|
|||||||
|
|
||||||
class DryerClient
|
class DryerClient
|
||||||
{
|
{
|
||||||
public function __construct(private IntegrationSettings $settings) {}
|
public function __construct(private IntegrationSettings $settings, private IntegrationCache $cache) {}
|
||||||
|
|
||||||
public function configured(): bool
|
public function configured(): bool
|
||||||
{
|
{
|
||||||
@@ -45,14 +45,14 @@ class DryerClient
|
|||||||
|
|
||||||
public function status(): array
|
public function status(): array
|
||||||
{
|
{
|
||||||
return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson()
|
return $this->cache->remember('dryer.status', 20, fn (): array => Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson()
|
||||||
->connectTimeout(3)->timeout(7)->get($this->url('status'))->throw()->json() ?? [];
|
->connectTimeout(3)->timeout(7)->get($this->url('status'))->throw()->json() ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function history(): array
|
public function history(): array
|
||||||
{
|
{
|
||||||
return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson()
|
return $this->cache->remember('dryer.history', 120, fn (): array => Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson()
|
||||||
->connectTimeout(3)->timeout(7)->get($this->url('history'), ['limit' => 60])->throw()->json() ?? [];
|
->connectTimeout(3)->timeout(7)->get($this->url('history'), ['limit' => 60])->throw()->json() ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function start(string $profile, int $targetTemperature, int $dryingTime): array
|
public function start(string $profile, int $targetTemperature, int $dryingTime): array
|
||||||
@@ -74,6 +74,9 @@ class DryerClient
|
|||||||
|
|
||||||
private function command(string $path, array $payload = []): array
|
private function command(string $path, array $payload = []): array
|
||||||
{
|
{
|
||||||
|
$this->cache->forget('dryer.status');
|
||||||
|
$this->cache->forget('dryer.history');
|
||||||
|
|
||||||
return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson()
|
return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson()
|
||||||
->connectTimeout(3)->timeout(7)->post($this->url($path), [
|
->connectTimeout(3)->timeout(7)->post($this->url($path), [
|
||||||
'requestId' => 'dashboard-'.bin2hex(random_bytes(12)), ...$payload,
|
'requestId' => 'dashboard-'.bin2hex(random_bytes(12)), ...$payload,
|
||||||
|
|||||||
+11
-7
@@ -11,6 +11,8 @@ class Etsy
|
|||||||
{
|
{
|
||||||
private const API_URL = 'https://api.etsy.com/v3';
|
private const API_URL = 'https://api.etsy.com/v3';
|
||||||
|
|
||||||
|
public function __construct(private IntegrationCache $cache) {}
|
||||||
|
|
||||||
/** @var array{keystring: ?string, shared_secret: ?string, shop_name: string, redirect_uri: ?string}|null */
|
/** @var array{keystring: ?string, shared_secret: ?string, shop_name: string, redirect_uri: ?string}|null */
|
||||||
private ?array $credentials = null;
|
private ?array $credentials = null;
|
||||||
|
|
||||||
@@ -62,18 +64,20 @@ class Etsy
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function receipts(EtsyConnection $connection): array
|
public function receipts(EtsyConnection $connection, bool $fresh = false): array
|
||||||
{
|
{
|
||||||
return $this->get('application/shops/'.$connection->shop_id.'/receipts', $this->accessToken($connection), [
|
return $this->cache->remember('etsy.receipts.'.$connection->shop_id, 300, fn (): array => $this->get(
|
||||||
'was_paid' => 'true', 'was_shipped' => 'false', 'limit' => 20,
|
'application/shops/'.$connection->shop_id.'/receipts', $this->accessToken($connection), [
|
||||||
]);
|
'was_paid' => 'true', 'was_shipped' => 'false', 'limit' => 20,
|
||||||
|
]), $fresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listings(EtsyConnection $connection): array
|
public function listings(EtsyConnection $connection): array
|
||||||
{
|
{
|
||||||
return $this->get('application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [
|
return $this->cache->remember('etsy.listings.'.$connection->shop_id, 600, fn (): array => $this->get(
|
||||||
'state' => 'active', 'limit' => 20,
|
'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [
|
||||||
]);
|
'state' => 'active', 'limit' => 20,
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function accessToken(EtsyConnection $connection): string
|
private function accessToken(EtsyConnection $connection): string
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-through cache for slow third-party API calls.
|
||||||
|
*
|
||||||
|
* Fresh entries are served instantly, expired entries fall back to the last good value if the
|
||||||
|
* upstream call fails, and a short failure cooldown stops every page load waiting on a dead API.
|
||||||
|
* The scheduled `integrations:refresh` command runs with $forceRefresh enabled to keep it warm.
|
||||||
|
*/
|
||||||
|
class IntegrationCache
|
||||||
|
{
|
||||||
|
private const RETAIN_SECONDS = 86400;
|
||||||
|
|
||||||
|
private const FAILURE_COOLDOWN_SECONDS = 30;
|
||||||
|
|
||||||
|
public bool $forceRefresh = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template TValue
|
||||||
|
*
|
||||||
|
* @param Closure(): TValue $fetch
|
||||||
|
* @return TValue
|
||||||
|
*/
|
||||||
|
public function remember(string $key, int $freshSeconds, Closure $fetch, bool $fresh = false): mixed
|
||||||
|
{
|
||||||
|
$cacheKey = 'integration:'.$key;
|
||||||
|
$entry = Cache::get($cacheKey);
|
||||||
|
$bypass = $fresh || $this->forceRefresh;
|
||||||
|
|
||||||
|
if (! $bypass && is_array($entry) && time() < $entry['fetched_at'] + $freshSeconds) {
|
||||||
|
return $entry['value'];
|
||||||
|
}
|
||||||
|
if (! $bypass && ! is_array($entry) && ($failure = Cache::get($cacheKey.':failed'))) {
|
||||||
|
throw new RuntimeException((string) $failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$value = $fetch();
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
if (is_array($entry)) {
|
||||||
|
report($exception);
|
||||||
|
|
||||||
|
return $entry['value'];
|
||||||
|
}
|
||||||
|
Cache::put($cacheKey.':failed', $exception->getMessage(), self::FAILURE_COOLDOWN_SECONDS);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
Cache::put($cacheKey, ['value' => $value, 'fetched_at' => time()], self::RETAIN_SECONDS);
|
||||||
|
Cache::forget($cacheKey.':failed');
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forget(string $key): void
|
||||||
|
{
|
||||||
|
Cache::forget('integration:'.$key);
|
||||||
|
Cache::forget('integration:'.$key.':failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
|||||||
use App\Models\IntegrationSetting;
|
use App\Models\IntegrationSetting;
|
||||||
use Illuminate\Http\Client\Response;
|
use Illuminate\Http\Client\Response;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
@@ -19,7 +20,7 @@ class Parcel2Go
|
|||||||
|
|
||||||
private ?string $profileToken = null;
|
private ?string $profileToken = null;
|
||||||
|
|
||||||
public function __construct(private IntegrationSettings $settings) {}
|
public function __construct(private IntegrationSettings $settings, private IntegrationCache $cache) {}
|
||||||
|
|
||||||
public function hasScope(string $scope): bool
|
public function hasScope(string $scope): bool
|
||||||
{
|
{
|
||||||
@@ -95,12 +96,13 @@ class Parcel2Go
|
|||||||
|
|
||||||
public function fetch(string $path): array
|
public function fetch(string $path): array
|
||||||
{
|
{
|
||||||
$token = str_starts_with(ltrim($path, '/'), 'me/') && $this->profileConnected()
|
$usesProfile = str_starts_with(ltrim($path, '/'), 'me/') && $this->profileConnected();
|
||||||
? $this->profileAccessToken()
|
$settings = $this->settings->for('parcel2go');
|
||||||
: $this->accessToken();
|
$key = 'parcel2go.'.($usesProfile ? 'profile' : 'client').'.'.md5(implode('|', [$this->url($path), $settings['client_id'] ?? '', $settings['profile_client_id'] ?? '']));
|
||||||
|
|
||||||
return Http::withToken($token)->acceptJson()->connectTimeout(3)->timeout(15)
|
return $this->cache->remember($key, 300, fn (): array => Http::withToken($usesProfile ? $this->profileAccessToken() : $this->accessToken())
|
||||||
->get($this->url($path))->throw()->json() ?? [];
|
->acceptJson()->connectTimeout(3)->timeout(15)
|
||||||
|
->get($this->url($path))->throw()->json() ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function quote(array $details): array
|
public function quote(array $details): array
|
||||||
@@ -240,6 +242,14 @@ class Parcel2Go
|
|||||||
throw new RuntimeException('Parcel2Go credentials are not configured.');
|
throw new RuntimeException('Parcel2Go credentials are not configured.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tokenKey = 'parcel2go.token.'.md5(implode('|', [$settings['auth_url'], $settings['client_id'], $settings['client_secret'], $settings['scope'] ?? '']));
|
||||||
|
$cached = Cache::get($tokenKey);
|
||||||
|
if (is_array($cached) && filled($cached['token'] ?? null)) {
|
||||||
|
$this->activeScope = $cached['scope'];
|
||||||
|
|
||||||
|
return $this->token = $cached['token'];
|
||||||
|
}
|
||||||
|
|
||||||
$scope = (string) ($settings['scope'] ?? 'public-api');
|
$scope = (string) ($settings['scope'] ?? 'public-api');
|
||||||
$response = $this->tokenResponse($settings, $scope);
|
$response = $this->tokenResponse($settings, $scope);
|
||||||
if ($scope !== 'public-api' && $response->status() === 400 && $response->json('error') === 'invalid_scope') {
|
if ($scope !== 'public-api' && $response->status() === 400 && $response->json('error') === 'invalid_scope') {
|
||||||
@@ -254,6 +264,7 @@ class Parcel2Go
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->activeScope = $scope;
|
$this->activeScope = $scope;
|
||||||
|
Cache::put($tokenKey, ['token' => $token, 'scope' => $scope], max(60, (int) $response->json('expires_in', 3600) - 120));
|
||||||
|
|
||||||
return $this->token = $token;
|
return $this->token = $token;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\DevCommands;
|
||||||
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
|
||||||
|
Schedule::command('integrations:refresh')->everyThirtySeconds()->withoutOverlapping(5);
|
||||||
|
|
||||||
|
DevCommands::artisan('schedule:work', 'schedule');
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use App\Services\IntegrationCache;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use RuntimeException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class IntegrationCacheTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Cache::flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_fresh_values_are_served_without_calling_the_api_again(): void
|
||||||
|
{
|
||||||
|
$cache = new IntegrationCache;
|
||||||
|
$calls = 0;
|
||||||
|
$fetch = function () use (&$calls): array {
|
||||||
|
$calls++;
|
||||||
|
|
||||||
|
return ['call' => $calls];
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->assertSame(['call' => 1], $cache->remember('orders', 60, $fetch));
|
||||||
|
$this->assertSame(['call' => 1], $cache->remember('orders', 60, $fetch));
|
||||||
|
$this->assertSame(['call' => 2], $cache->remember('orders', 60, $fetch, true));
|
||||||
|
$this->assertSame(2, $calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_last_good_value_is_served_when_the_api_fails_after_expiry(): void
|
||||||
|
{
|
||||||
|
$cache = new IntegrationCache;
|
||||||
|
$cache->remember('orders', 0, fn (): array => ['ok' => true]);
|
||||||
|
|
||||||
|
$this->assertSame(['ok' => true], $cache->remember('orders', 0, function (): never {
|
||||||
|
throw new RuntimeException('API down');
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_failures_with_no_cached_value_are_not_retried_during_cooldown(): void
|
||||||
|
{
|
||||||
|
$cache = new IntegrationCache;
|
||||||
|
$calls = 0;
|
||||||
|
$failing = function () use (&$calls): never {
|
||||||
|
$calls++;
|
||||||
|
|
||||||
|
throw new RuntimeException('API down');
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (range(1, 3) as $attempt) {
|
||||||
|
try {
|
||||||
|
$cache->remember('orders', 60, $failing);
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame(1, $calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_refresh_flag_bypasses_fresh_entries(): void
|
||||||
|
{
|
||||||
|
$cache = new IntegrationCache;
|
||||||
|
$cache->remember('orders', 60, fn (): array => ['v' => 1]);
|
||||||
|
$cache->forceRefresh = true;
|
||||||
|
|
||||||
|
$this->assertSame(['v' => 2], $cache->remember('orders', 60, fn (): array => ['v' => 2]));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user