🦸🏻♂️ Introductie: Headless WordPress zonder WordPress
Sinds het debacle tussen Matt Mullenweg en WPEngine valt het me op dat steeds meer mensen op Reddit (en elders) vragen naar alternatieven voor WordPress — niet zozeer om er meteen mee te stoppen (tenminste niet direct), maar om te begrijpen welke opties ze hebben en hoe pijnlijk een eventuele migratie zou zijn. Ze willen weten hoe ze zich kunnen indekken voor verschillende scenario's.
Voor mensen die werken met headless WordPress biedt Gato GraphQL nu een interessante nieuwe functie: Headless WordPress zonder WordPress.
Dit artikel legt alles uit, beschrijft hoe dit überhaupt mogelijk is en toont een demonstratievideo.
Gato GraphQL uitvoeren als zelfstandige PHP-applicatie
Gato GraphQL is gebouwd met zelfstandige PHP-componenten, beheerd via Composer, op zo'n manier dat alle PHP-componenten die de GraphQL-server vormen niet afhankelijk zijn van WordPress!
Daardoor kan de GraphQL-server draaien als een zelfstandige PHP-applicatie en kun je hem opnemen in elke PHP-applicatie, of die nu op WordPress gebaseerd is of op iets anders.
Als je applicatie voor een bepaald gebruik geen toegang nodig heeft tot WordPress-data, ben je voor dat gebruik al klaar om te gaan.
Deze video demonstreert zo'n gebruik: communiceren met de GitHub API om artefacten van GitHub Actions te downloaden/installeren tijdens de ontwikkeling:
In de video verstuurt de GraphQL query een HTTP-verzoek om de nieuwste Gato GraphQL-plugins op te halen die gegenereerd zijn in GitHub Actions, welke als artefacten worden geüpload bij het samenvoegen van een pull request.
De URL's van de artefacten uit het GraphQL-antwoord worden vervolgens doorgegeven aan WP-CLI, zodat de plugins automatisch worden geïnstalleerd op een lokale DEV-webserver om tests uit te voeren.
(Ik leg dit in de laatste sectie van dit artikel gedetailleerder uit.)
In dit gebruik, omdat er helemaal geen WordPress-data wordt benaderd, kan de GraphQL-server al draaien als een zelfstandige PHP-applicatie.
Als ik dat zou willen, kon ik hem zelfs gebruiken binnen mijn GitHub Actions-workflow!
Een headless WordPress-applicatie migreren
Wanneer je wel WordPress-data benadert, laten we kijken hoe je dat zonder WordPress kunt uitvoeren.
Het GraphQL-schema van Gato GraphQL bevat velden om WordPress-data op te halen: posts, users, comments, tags, categories, enz.
De code in de PHP-resolvers die WordPress-data ophalen is afhankelijk van WordPress; die code kan niet draaien in een niet-WordPress-applicatie.
Gato GraphQL heeft echter elk van deze resolvers geïmplementeerd via 2 pakketten:
- Een "vanilla" PHP-pakket, met alle generieke code
- Een WordPress-specifiek pakket, met de daadwerkelijke aanroepen naar WordPress-methoden die de resolver afhandelen
Bijvoorbeeld in deze GraphQL query:
{
posts {
id
title
}
}...bestaat de logica voor het ophalen van posts uit:
- Het
Root.posts-veld: het staat in het generiekeposts-pakket - De afhandeling ervan voor WordPress via de
get_posts-methode: het staat in het WordPress-specifiekeposts-wp-pakket.
De codeverdeling tussen niet-WordPress/WordPress-pakketten ligt rond de 80/20%, wat betekent dat 80% van de code herbruikbaar is met een ander framework/CMS en slechts 20% van de code opnieuw geïmplementeerd zou moeten worden.
Bovendien wordt alle functionaliteit in Gato GraphQL geleverd via modules, en modules kunnen naar wens in- of uitgeschakeld worden.

Modules is een functie die geïmplementeerd is om beveiligingsredenen: als je geen gebruikersdata hoeft bloot te stellen in je publieke API, kun je de Users-module uitschakelen en worden de bijbehorende velden (zoals Root.users) nooit aan het schema toegevoegd.
Modules zijn direct gekoppeld aan de onderliggende PHP-pakketten. Daardoor kunnen we bij het uitvoeren van Gato GraphQL als zelfstandige app selectief die modules/pakketten laden die we nodig hebben, en geen andere.
Als je applicatie bijvoorbeeld alleen data voor posts, categorieën en tags weergeeft, hoeven alleen de pakketten posts-wp, categories-wp en tags-wp (samen met hun afhankelijkheden) te worden geladen.
Als je dan van WordPress migreert (bijvoorbeeld naar Laravel of Symfony), hoeven alleen die 3 WordPress-specifieke pakketten opnieuw geïmplementeerd te worden voor het nieuwe framework/CMS, en niets anders.
Als gevolg hiervan kun je headless WordPress vandaag nog gebruiken, wetende dat je later je applicatie kunt migreren naar een ander framework of CMS met minimale inspanning.
Overstappen naar Gato GraphQL vanuit een andere API
Als je al headless WordPress gebruikt, is de kans groot dat je applicatie gebruikmaakt van de WP REST API of WPGraphQL.
Helaas ben je met beide API's gebonden aan WordPress: er is geen WP REST API buiten WordPress, en WPGraphQL kan niet draaien zonder WordPress.
Gelukkig is het mogelijk om een van beide te vervangen door Gato GraphQL en zo de mogelijkheid te krijgen om je headless WordPress-applicatie weg te migreren van WordPress.
Daarvoor zijn dan 2 stappen nodig:
- Overstappen van WP REST API of WPGraphQL naar Gato GraphQL
- De vereiste WordPress-specifieke pakketten opnieuw implementeren
Laten we kijken hoe de API-overstap gedaan kan worden.
WP REST API naar Gato GraphQL's persisted queries
Met de extensie Persisted Queries kun je REST-achtige endpoints publiceren, samengesteld met GraphQL.
Voor elk REST-endpoint in je applicatie kun je een bijbehorend persisted query-endpoint aanmaken dat dezelfde data ophaalt, en dat endpoint gebruiken in plaats van het oorspronkelijke.
De volgende GraphQL query kan bijvoorbeeld het REST-endpoint /wp-json/wp/v2/posts/ vervangen:
{
posts {
id
date: dateStr(format: "Y-m-d\\TH:i:s")
modified: modifiedDateStr(format: "Y-m-d\\TH:i:s")
slug
status
link: url
title: self {
rendered: title
}
content: self {
rendered: content
},
excerpt: self {
rendered: excerpt
}
author
featured_media: featuredImage
sticky: isSticky
categories
tags
}
}Dankzij de API-hiërarchie kan de persisted query gepubliceerd worden onder het pad /graphql-query/wp/v2/posts/, wat het eenvoudig maakt om endpoints te koppelen.
Om het REST-endpoint /wp-json/wp/v2/posts/{id}/ te repliceren, dat data ophaalt voor de post met het opgegeven ID, kunnen we de post-ID opgeven via de URL-parameter postId.
De volgende persisted query kan bijvoorbeeld worden aangeroepen onder het endpoint /graphql-query/wp/v2/posts/single/?postId={id}:
query GetPost($postId: ID!) {
post(by: { id: $postId }) {
id
date: dateStr(format: "Y-m-d\\TH:i:s")
modified: modifiedDateStr(format: "Y-m-d\\TH:i:s")
slug
status
link: url
title: self {
rendered: title
}
content: self {
rendered: content
},
excerpt: self {
rendered: excerpt
}
author
featured_media: featuredImage
sticky: isSticky
categories
tags
}
}WPGraphQL naar Gato GraphQL
Het GraphQL-schema van WPGraphQL en Gato GraphQL zijn vergelijkbaar maar enigszins verschillend, dus ze moeten worden aangepast.
De Next.js WordPress-starter leoloso/next-wordpress-starter werkt met zowel WPGraphQL als Gato GraphQL. De starter gebruikt dezelfde JS-logica voor beide servers; alleen de GraphQL queries zijn verschillend.
Deze starter biedt verschillende voorbeelden van het aanpassen van queries tussen de twee servers. Bijvoorbeeld deze WPGraphQL query:
fragment PostFields on Post {
id
categories {
edges {
node {
databaseId
id
name
slug
}
}
}
databaseId
date
isSticky
postId
slug
title
}...wordt als volgt aangepast voor Gato GraphQL:
fragment PostFields on Post {
id
categories: self {
edges: categories(pagination: { limit: -1 }) {
node: self {
databaseId: id
id
name
slug
}
}
}
databaseId: id
date: dateStr
isSticky
postId: id
slug
title
}In detail: Gato GraphQL uitvoeren als zelfstandige PHP-applicatie
Hier volgt de gedetailleerde uitleg van de demonstratievideo van eerder.
We geven de GraphQL query die uitgevoerd moet worden mee in het bestand retrieve-github-artifacts.gql.
De query maakt verbinding met de GitHub API door het toegangstoken op te halen uit de omgevingsvariabele GITHUB_ACCESS_TOKEN. Het genereert dynamisch het volledige pad voor het actions/artifacts-endpoint op basis van de opgegeven variabelen en verstuurt vervolgens een HTTP-verzoek daartegen.
Uit het antwoord wordt dan de "download URL" uit elk artefact-item geëxtraheerd en worden er asynchrone HTTP-verzoeken naartoe gestuurd. Uit de Location-header van elk van deze "download URL's" halen we de werkelijke URL van het downloadbare bestand op.
Tot slot worden alle URL's samen afgedrukt, gescheiden door een spatie, om ze gemakkelijk in te kunnen voegen in WP-CLI.
# File retrieve-github-artifacts.gql
query RetrieveProxyArtifactDownloadURLs(
$repoOwner: String!
$repoProject: String!
$perPage: Int = 1
$artifactName: String = ""
) {
githubAccessToken: _env(name: "GITHUB_ACCESS_TOKEN")
@remove
# Create the authorization header to send to GitHub
authorizationHeader: _sprintf(
string: "Bearer %s"
values: [$__githubAccessToken]
)
@remove
# Create the authorization header to send to GitHub
githubRequestHeaders: _echo(
value: [
{ name: "Accept", value: "application/vnd.github+json" }
{ name: "Authorization", value: $__authorizationHeader }
]
)
@remove
@export(as: "githubRequestHeaders")
githubAPIEndpoint: _sprintf(
string: "https://api.github.com/repos/%s/%s/actions/artifacts?per_page=%s&name=%s"
values: [$repoOwner, $repoProject, $perPage, $artifactName]
)
# Use the field from "Send HTTP Request Fields" to connect to GitHub
gitHubArtifactData: _sendJSONObjectItemHTTPRequest(
input: {
url: $__githubAPIEndpoint
options: { headers: $__githubRequestHeaders }
}
)
@remove
# Finally just extract the URL from within each "artifacts" item
gitHubProxyArtifactDownloadURLs: _objectProperty(
object: $__gitHubArtifactData
by: { key: "artifacts" }
)
@underEachArrayItem(passValueOnwardsAs: "artifactItem")
@applyField(
name: "_objectProperty"
arguments: { object: $artifactItem, by: { key: "archive_download_url" } }
setResultInResponse: true
)
@export(as: "gitHubProxyArtifactDownloadURLs")
}
query CreateHTTPRequestInputs
@depends(on: "RetrieveProxyArtifactDownloadURLs")
{
httpRequestInputs: _echo(value: $gitHubProxyArtifactDownloadURLs)
@underEachArrayItem(passValueOnwardsAs: "url")
@applyField(
name: "_objectAddEntry"
arguments: {
object: {
options: { headers: $githubRequestHeaders, allowRedirects: null }
}
key: "url"
value: $url
}
setResultInResponse: true
)
@export(as: "httpRequestInputs")
@remove
}
query RetrieveActualArtifactDownloadURLs
@depends(on: "CreateHTTPRequestInputs")
{
_sendHTTPRequests(inputs: $httpRequestInputs) {
artifactDownloadURL: header(name: "Location")
@export(as: "artifactDownloadURLs", type: LIST)
}
}
query PrintSpaceSeparatedArtifactDownloadURLs
@depends(on: "RetrieveActualArtifactDownloadURLs")
{
spaceSeparatedArtifactDownloadURLs: _arrayJoin(
array: $artifactDownloadURLs
separator: " "
)
}De PHP-logica laadt de code direct vanuit de Gato GraphQL-plugin en het "Power Extensions"-bundel (nodig om HTTP-verzoeken te versturen en andere functionaliteit).
Als zelfstandige PHP-applicatie moeten we expliciet aangeven welke modules worden geïnitialiseerd en eventuele niet-standaard configuratie opgeven.
We vertellen de module SendHTTPRequests bijvoorbeeld om verbinding te mogen maken met https://api.github.com/repos, en de module EnvironmentFields om toegang te mogen hebben tot de omgevingsvariabele GITHUB_ACCESS_TOKEN.
Merk op dat het GraphQL-schema de eerste keer dat de GraphQL query wordt uitgevoerd wordt gegenereerd en op schijf wordt gecached. Zo wordt vanaf de tweede keer geen enkele code voor het berekenen van het schema meer uitgevoerd, waardoor de uitvoering sneller gaat.
Tot slot initialiseert de zelfstandige applicatie de GraphQL-server, voert de query uit en drukt het antwoord af.
<?php
// File retrieve-github-artifacts.php
declare(strict_types=1);
use GraphQLByPoP\GraphQLServer\Server\StandaloneGraphQLServer;
use PoP\Root\Container\ContainerCacheConfiguration;
// Load the GraphQL server via the standalone PHP components
require_once (__DIR__ . '/wordpress/wp-content/plugins/gatographql/vendor/scoper-autoload.php');
// Load the PRO extensions via the standalone PHP components
require_once (__DIR__ . '/wordpress/wp-content/plugins/gatographql-power-extensions-bundle/vendor/scoper-autoload.php');
// Modules required in the GraphQL query
$moduleClasses = [
\PoPSchema\EnvironmentFields\Module::class,
\PoPSchema\FunctionFields\Module::class,
\GraphQLByPoP\ExportDirective\Module::class,
\GraphQLByPoP\DependsOnOperationsDirective\Module::class,
\GraphQLByPoP\RemoveDirective\Module::class,
\PoPSchema\ApplyFieldDirective\Module::class,
\PoPSchema\SendHTTPRequests\Module::class,
\PoPSchema\ConditionalMetaDirectives\Module::class,
\PoPSchema\DataIterationMetaDirectives\Module::class,
];
// Configure the modules
$moduleClassConfiguration = [
\PoP\GraphQLParser\Module::class => [
\PoP\GraphQLParser\Environment::ENABLE_MULTIPLE_QUERY_EXECUTION => true,
\PoP\GraphQLParser\Environment::USE_LAST_OPERATION_IN_DOCUMENT_FOR_MULTIPLE_QUERY_EXECUTION_WHEN_OPERATION_NAME_NOT_PROVIDED => true,
\PoP\GraphQLParser\Environment::ENABLE_RESOLVED_FIELD_VARIABLE_REFERENCES => true,
\PoP\GraphQLParser\Environment::ENABLE_COMPOSABLE_DIRECTIVES => true,
],
\PoPSchema\SendHTTPRequests\Module::class => [
\PoPSchema\SendHTTPRequests\Environment::SEND_HTTP_REQUEST_URL_ENTRIES => [
'#https://api.github.com/repos/(.*)#',
],
],
\PoPSchema\EnvironmentFields\Module::class => [
\PoPSchema\EnvironmentFields\Environment::ENVIRONMENT_VARIABLE_OR_PHP_CONSTANT_ENTRIES => [
'GITHUB_ACCESS_TOKEN',
],
],
];
// Cache the schema to disk, to speed-up execution from the 2nd time onwards
$containerCacheConfiguration = new ContainerCacheConfiguration('MyGraphQLServer', true, 'retrieve-github-artifacts', __DIR__ . '/tmp');
// Initialize the server
$graphQLServer = new StandaloneGraphQLServer($moduleClasses, $moduleClassConfiguration, [], [], $containerCacheConfiguration);
/**
* GraphQL query to execute, stored in its own .gql file
*
* @var string
*/
$query = file_get_contents(__DIR__ . '/retrieve-github-artifacts.gql');
// GraphQL variables
$variables = [
'repoOwner' => 'GatoGraphQL',
'repoProject' => 'GatoGraphQL',
'perPage' => 3
];
// Execute the query
$response = $graphQLServer->execute(
$query,
$variables,
);
// Print the response
echo $response->getContent();Om de GraphQL query uit te voeren, draaien we het volgende in de terminal (met jq om de JSON-uitvoer mooi op te maken):
php retrieve-github-artifacts.php | jqTot slot, om de artefact-URL's uit het GraphQL-antwoord te extraheren en in WP-CLI te injecteren, draaien we:
GITHUB_ARTIFACT_URLS=$(php retrieve-github-artifacts.php \
| grep -E -o '"spaceSeparatedArtifactDownloadURLs\":"(.*)"' \
| cut -d':' -f2- | cut -d'"' -f2- | rev | cut -d'"' -f2- | rev \
| sed 's/\\\//\//g')
wp plugin install ${GITHUB_ARTIFACT_URLS} --force --activateZoals te zien in de video, zijn we in staat om Gato GraphQL uit te voeren zonder WordPress.