Integration guide (PHP)
This page gives you a ready-to-use client to call Goodays GEO fragment API from your server. It implements every recommendation of Integration best practices.
What this client does
A developer copies the client into your website's server code. Each time a visitor or a crawler opens the page of a point of sale, the client:
- asks Goodays for the fragment of that point of sale;
- keeps it in a cache for 24 hours, so your pages don't call Goodays on every visit;
- gives up after 2 seconds if Goodays is slow, so your page is never delayed;
- if Goodays is unreachable, serves the last version it received, or renders the page without the reviews block.
Your code then only calls one function with the external ID of the point of sale, and inserts the result in the page.
Requirements
Client: src/FragmentClient.php. It needs PHP 8.1+, Guzzle and any PSR-16 cache. By default it uses a symfony/cache filesystem cache.
In the examples below:
madrigal-los-pollos-hermanosis a universe ID (one of your brands): replace it with yours. In the client, it is the$clientIdparameter.ABQ-001is an external ID (your own code for the point of sale): replace it with the code of the point of sale the page is about.
Your Goodays project contact gives you your universe ID and confirms your external IDs.
Use it in your project
Copy src/FragmentClient.php into your project, keeping its namespace or adapting it, and require its dependencies:
composer require guzzlehttp/guzzle symfony/cacheuse Goodays\Geo\FragmentClient;
$geo = new FragmentClient('madrigal-los-pollos-hermanos');
$fragment = $geo->getFragment('ABQ-001'); // string, or null: render the page without reviewsOutput the fragment unescaped in your template:
| Template | Syntax |
|---|---|
| Plain PHP | <?= $fragment ?> |
| Laravel Blade | {!! $fragment !!} |
| Twig / Symfony | {{ fragment|raw }} |
| WordPress | echo $fragment; |
In WordPress, don't pass the fragment through wp_kses_post(). It would strip the JSON-LD <script>, the inline <style> block, the script of the "See more reviews" button and the stylesheet link.
Options
new FragmentClient(
'madrigal-los-pollos-hermanos',
http: null, // your own GuzzleHttp\ClientInterface (proxy, middleware...)
cache: null, // any Psr\SimpleCache\CacheInterface
options: [
'base_url' => 'https://geo.goodays.co',
'connect_timeout' => 1.0,
'timeout' => 2.0,
'fresh_ttl' => 86400, // fragments are regenerated every 24 h
'not_found_ttl' => 3600,
'error_backoff' => 60,
'stale_max' => 604800,
'live' => false, // development only: live data, no cache
],
logger: $psr3Logger, // fallbacks are logged as warnings
);Shared cache
PHP doesn't keep memory between requests, so a persistent cache is required. The default filesystem cache, stored in sys_get_temp_dir(), is shared by all PHP-FPM workers of one server. With several servers, use Redis or Memcached:
// Laravel: the cache repository implements PSR-16.
$geo = new FragmentClient('madrigal-los-pollos-hermanos', cache: app('cache.store'), logger: app('log'));
// Symfony / plain PHP: wrap any PSR-6 adapter.
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Cache\Psr16Cache;
$redis = RedisAdapter::createConnection('redis://localhost');
$geo = new FragmentClient('madrigal-los-pollos-hermanos', cache: new Psr16Cache(new RedisAdapter($redis, 'goodays-geo')));Client code
Copy this file into your project as FragmentClient.php.
<?php
declare(strict_types=1);
namespace Goodays\Geo;
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Psr\SimpleCache\CacheInterface;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Psr16Cache;
/**
* Goodays GEO Fragments API client.
*
* Fetches the pre-rendered reviews fragment (HTML + JSON-LD) of a place and
* keeps it in a local cache, so rendering your pages never depends on a live
* call to Goodays:
*
* - fresh cache hit: no network call at all;
* - expired entry: conditional request (ETag / Last-Modified), usually a 304;
* - error, timeout or unexpected response: the last good fragment is served
* (stale-on-error) and the origin is not retried before `error_backoff`;
* - unknown place (404) or nothing cached: null, render the page without the
* reviews block.
*
* Any PSR-16 cache works. The default filesystem cache is shared by all
* PHP-FPM workers of a server; use Redis/Memcached across several servers.
*/
final class FragmentClient
{
public const DEFAULT_BASE_URL = 'https://geo.goodays.co';
private const USER_AGENT = 'goodays-geo-snippets/php/1.0';
private const DEFAULT_OPTIONS = [
'base_url' => self::DEFAULT_BASE_URL,
'connect_timeout' => 1.0, // seconds
'timeout' => 2.0, // seconds, whole request
'fresh_ttl' => 86400, // fragments are regenerated every 24 h
'not_found_ttl' => 3600,
'error_backoff' => 60,
'stale_max' => 604800, // 7 days
'live' => false, // development only: bypass every cache, live data
];
private readonly ClientInterface $http;
private readonly CacheInterface $cache;
/** @var array<string, mixed> */
private readonly array $options;
/** @param array<string, mixed> $options see DEFAULT_OPTIONS */
public function __construct(
private readonly string $clientId,
?ClientInterface $http = null,
?CacheInterface $cache = null,
array $options = [],
private readonly LoggerInterface $logger = new NullLogger(),
) {
$this->http = $http ?? new Client();
$this->cache = $cache ?? new Psr16Cache(new FilesystemAdapter('goodays-geo'));
$this->options = $options + self::DEFAULT_OPTIONS;
}
public function fragmentUrl(string $place): string
{
return rtrim($this->options['base_url'], '/')
. '/clients/' . rawurlencode($this->clientId)
. '/' . rawurlencode($place)
. ($this->options['live'] ? '?live=true' : '');
}
/** Returns the fragment HTML to output as-is, or null. */
public function getFragment(string $place): ?string
{
// PSR-16 keys can't contain characters like "{}()/\@:".
$key = 'goodays-geo.' . sha1($this->clientId . "\0" . $place);
$entry = $this->cache->get($key);
if (!$this->options['live'] && is_array($entry) && time() < $entry['fresh_until']) {
return $entry['html'];
}
$entry = is_array($entry) ? $entry : null;
$headers = ['Accept' => 'text/html', 'User-Agent' => self::USER_AGENT];
if (!empty($entry['etag'])) {
$headers['If-None-Match'] = $entry['etag'];
}
if (!empty($entry['last_modified'])) {
$headers['If-Modified-Since'] = $entry['last_modified'];
}
try {
$response = $this->http->request('GET', $this->fragmentUrl($place), [
'headers' => $headers,
'connect_timeout' => $this->options['connect_timeout'],
'timeout' => $this->options['timeout'],
'http_errors' => false,
]);
} catch (GuzzleException $e) {
return $this->fallback($key, $entry, 'request failed: ' . $e->getMessage());
}
$status = $response->getStatusCode();
if ($status === 304 && $entry !== null) {
return $this->store($key, $entry['html'], $entry['etag'], $entry['last_modified']);
}
$contentType = $response->getHeaderLine('Content-Type');
if ($status === 200 && str_starts_with(strtolower($contentType), 'text/html')) {
$html = (string) $response->getBody();
if (trim($html) !== '') {
return $this->store(
$key,
$html,
$response->getHeaderLine('ETag') ?: null,
$response->getHeaderLine('Last-Modified') ?: null,
);
}
}
if ($status === 404) {
$this->logger->info('No GEO fragment for {client}/{place} (404)', [
'client' => $this->clientId,
'place' => $place,
]);
$this->storeMiss($key, $this->options['not_found_ttl']);
return null;
}
return $this->fallback($key, $entry, "unexpected response: HTTP $status $contentType");
}
private function store(string $key, ?string $html, ?string $etag, ?string $lastModified): ?string
{
$now = time();
$this->cache->set($key, [
'html' => $html,
'etag' => $etag,
'last_modified' => $lastModified,
'fresh_until' => $now + $this->options['fresh_ttl'],
// Kept that long to be served as a stale copy if Goodays is unreachable.
'expires_at' => $now + $this->options['stale_max'],
], $this->options['stale_max']);
return $html;
}
/** Remember there is nothing to show, so the origin isn't hit on every page view. */
private function storeMiss(string $key, int $ttl): void
{
$now = time();
$this->cache->set($key, [
'html' => null,
'etag' => null,
'last_modified' => null,
'fresh_until' => $now + $ttl,
'expires_at' => $now + $ttl,
], $ttl);
}
/** @param array<string, mixed>|null $entry */
private function fallback(string $key, ?array $entry, string $reason): ?string
{
$this->logger->warning('GEO fragment {key} unavailable ({reason}), serving {serving}', [
'key' => $key,
'reason' => $reason,
'serving' => isset($entry['html']) ? 'stale copy' : 'page without reviews',
]);
// Don't call the origin again before error_backoff, whatever happens.
if (isset($entry['html'])) {
$remaining = $entry['expires_at'] - time();
if ($remaining > 0) {
$entry['fresh_until'] = time() + $this->options['error_backoff'];
$this->cache->set($key, $entry, $remaining);
return $entry['html'];
}
}
$this->storeMiss($key, $this->options['error_backoff']);
return null;
}
}Updated about 1 hour ago