Pantheon Quicksilver Hooks: A Complete Playbook for WordPress Workflow Automation
Why Quicksilver Matters for WordPress Teams on Pantheon
Every WordPress team that deploys to Pantheon eventually hits the same wall. The code push goes fine. The database update runs. But then someone forgets to flush the object cache. Or nobody remembers to notify the QA channel. Or a critical post-deploy script gets skipped because the developer was juggling three browser tabs and a Slack thread.
Quicksilver exists to eliminate that entire class of human error. It is Pantheon’s built-in workflow automation framework, and it hooks directly into platform-level events: code deploys, cache clears, database clones, and more. When one of these events fires, Quicksilver executes scripts you define. No cron jobs. No external CI triggers. No hoping someone remembers to run the post-deploy checklist.
For WordPress specifically, Quicksilver fills a gap that most hosting platforms leave wide open. WordPress has its own hook system (actions and filters), but those only fire during HTTP requests or WP-CLI commands. They do not fire when Pantheon moves code between environments, clones a database from live to dev, or clears its edge cache. Quicksilver bridges that gap by giving you programmable responses to infrastructure events.
If you have spent any time managing WordPress sites on Pantheon, you already know the platform enforces a strict Dev, Test, Live workflow. Code flows up. Content flows down. Quicksilver gives you the ability to run automated tasks at every transition point in that workflow. Think of it as Git hooks, but for your entire hosting platform.
This article walks through everything you need to build a production-grade Quicksilver setup for WordPress. We will cover configuration, scripting, secrets management, debugging, and real-world examples drawn from actual agency deployments. The code samples are all syntactically correct and tested against Pantheon’s execution environment.
Understanding the Pantheon WebOps Workflow
Before writing a single Quicksilver script, you need to understand how Pantheon’s deployment pipeline works. This context is essential because Quicksilver hooks fire at specific points in that pipeline, and misunderstanding the timing will cause your scripts to fail silently or run against the wrong state.
Pantheon structures every site into three environments: Dev, Test, and Live. Each environment has its own codebase, database, and filesystem. The fundamental rule is straightforward: code moves from Dev to Test to Live through tagged deployments. Database and file content can be cloned in the opposite direction, from Live back to Dev or Test, for local development and staging.
When you push a Git commit to the Dev environment, Pantheon detects the code change and fires a sync_code event. When you deploy from Dev to Test (or Test to Live) through the Pantheon Dashboard or Terminus CLI, it fires a deploy event. When you clone the database from Live to Dev, it fires a db_clone event. Each of these events can trigger Quicksilver scripts.
The execution model is simple. You define your hooks in a file called pantheon.yml at the root of your repository. Each hook specifies an event, a timing (before or after), and a script to run. The scripts are PHP files that Pantheon executes in an isolated context with access to your site’s codebase and a set of environment variables.
Here is the critical detail that trips up newcomers: Quicksilver scripts run in a limited execution environment. They have a 120-second timeout. They cannot make outbound SSH connections. They run as the web user with the same permissions as your WordPress site. They have access to the full WordPress codebase but do not automatically bootstrap WordPress itself. If you need WordPress functions, you must manually include wp-load.php.
pantheon.yml Configuration Deep Dive
The pantheon.yml file sits at the root of your Git repository. It controls Quicksilver hooks along with other Pantheon platform settings like PHP version, nested docroot configuration, and protected web paths. For WordPress projects, a well-structured pantheon.yml typically looks like this:
api_version: 1
php_version: 8.2
workflows:
deploy:
after:
- type: webphp
description: Flush WordPress caches after deploy
script: private/scripts/clear_cache.php
- type: webphp
description: Run database migrations
script: private/scripts/db_migrate.php
- type: webphp
description: Notify Slack channel
script: private/scripts/slack_notify.php
sync_code:
after:
- type: webphp
description: Clear opcache after code sync
script: private/scripts/clear_opcache.php
clear_cache:
after:
- type: webphp
description: Log cache clear events
script: private/scripts/log_cache_clear.php
clone_database:
after:
- type: webphp
description: Sanitize database after clone
script: private/scripts/sanitize_db.php
- type: webphp
description: Update search-replace URLs
script: private/scripts/search_replace.php
Let us break down each piece.
API Version
The api_version: 1 line is required and must always be 1. Pantheon has not released a v2 API, so this is static for now.
PHP Version
Setting php_version in pantheon.yml controls the PHP version across all environments for your site. For WordPress sites in 2022 and beyond, you should be running at least PHP 8.0. PHP 8.2 is fully supported and recommended.
Workflow Definitions
The workflows key is where Quicksilver hooks live. Each workflow corresponds to a platform event. Under each workflow, you specify before or after timing, then list one or more scripts.
Each script entry requires three fields:
type: Currently, the only supported type is webphp. This means the script is a PHP file executed via the web server. Pantheon has discussed adding other types in the future, but webphp is what you will use.
description: A human-readable string that appears in the Pantheon Dashboard workflow log. Make these descriptive. When something fails at 2 AM, you will be grateful for clear descriptions.
script: The path to the PHP file, relative to your repository root. Convention places these in private/scripts/, which maps to the private directory on Pantheon’s filesystem. This directory is not web-accessible, keeping your automation scripts safe from direct HTTP requests.
Script Execution Order
Scripts listed under a workflow execute sequentially, in the order they appear in pantheon.yml. If the first script fails, subsequent scripts still execute. Quicksilver does not support conditional execution or dependency chains between scripts. Each script runs independently, and a failure in one does not block the others.
This has practical implications. If your Slack notification script runs before your database migration script, and the migration fails, the Slack message will already have reported a successful deploy. Structure your scripts so that notifications fire last.
Available Hooks and Their Timing
Pantheon exposes several workflow events to Quicksilver. Each fires under specific conditions, and understanding the differences is essential for building reliable automation.
deploy
Fires when code is deployed from one environment to another: Dev to Test, or Test to Live. This is the most commonly used hook. The after timing runs your script immediately after the code is in place on the target environment.
Use this for: cache flushing, database migrations, Slack notifications, visual regression test triggers, CDN purges.
Do not confuse deploy with sync_code. A deploy is an explicit promotion through the Pantheon workflow. Pushing code to the Dev environment via Git is not a deploy; it is a sync_code event.
sync_code
Fires when code arrives in the Dev environment via Git push (or when code is committed through SFTP mode). This hook does not fire on Test or Live environments because those environments receive code through deploys, not direct pushes.
Use this for: clearing opcache, running linters, updating development dependencies, notifying the dev team.
A common mistake is putting production deployment logic in sync_code. Remember, sync_code only fires on Dev.
clear_cache
Fires when the Pantheon edge cache (Varnish/Advanced Global CDN) is cleared, either manually through the Dashboard, via Terminus, or programmatically through the API.
Use this for: logging cache clear events, warming critical URLs, notifying monitoring systems.
clone_database
Fires when a database is cloned from one environment to another. On WordPress sites, this typically happens when a developer pulls the Live database down to Dev for local development.
Use this for: sanitizing user data, replacing URLs (live domain to dev domain), resetting passwords, disabling production-only plugins.
clone_files
Fires when the filesystem (the wp-content/uploads directory) is cloned between environments. Less commonly hooked, but useful for logging or triggering image optimization pipelines.
create_cloud_development_environment
Fires when a Multidev environment is created. Multidev environments are feature-branch environments that Pantheon provisions on demand. This hook is valuable for agencies running multiple feature branches simultaneously.
Use this for: setting up environment-specific configuration, notifying project management tools, provisioning test data.
Before vs. After Timing
Most hooks support both before and after timing. The before scripts run before the platform action completes. For deploys, this means the old code is still in place when your script runs. For database clones, the old database is still present.
In practice, after is far more useful for WordPress sites. You almost always want to act on the new state: flush caches for the new code, run migrations against the new database, notify the team that the deploy is complete.
The one strong use case for before timing is creating backups or snapshots before a destructive operation like a database clone.
Writing webphp Scripts for Post-Deploy Tasks
Quicksilver scripts are plain PHP files. They do not run inside a WordPress context by default, which means you cannot call functions like wp_cache_flush() without first bootstrapping WordPress. Let us walk through the most common script patterns.
Cache Clearing Script
After every deploy, you want to ensure that WordPress object caches and any persistent caches are flushed. Here is a reliable cache-clearing script:
<?php
// private/scripts/clear_cache.php
// Clears WordPress object cache and opcache after deploy.
echo "Starting cache clear process...n";
// Clear PHP opcache if available
if (function_exists('opcache_reset')) {
opcache_reset();
echo "OPcache cleared.n";
} else {
echo "OPcache not available, skipping.n";
}
// Bootstrap WordPress to access WP functions
$_SERVER['REQUEST_URI'] = '/';
$_SERVER['HTTP_HOST'] = $_ENV['PANTHEON_ENVIRONMENT'] . '-' . $_ENV['PANTHEON_SITE_NAME'] . '.pantheonsite.io';
require_once $_SERVER['DOCUMENT_ROOT'] . '/wp-load.php';
// Flush WordPress object cache
if (function_exists('wp_cache_flush')) {
wp_cache_flush();
echo "WordPress object cache flushed.n";
}
// Clear transients
global $wpdb;
$cleared = $wpdb->query(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_%'"
);
echo "Cleared {$cleared} transient rows.n";
// Flush rewrite rules
flush_rewrite_rules();
echo "Rewrite rules flushed.n";
echo "Cache clear complete.n";
A few things to note about this script. First, we manually set $_SERVER['REQUEST_URI'] and $_SERVER['HTTP_HOST'] before requiring wp-load.php. This is necessary because Quicksilver runs scripts outside of a normal HTTP request context, and WordPress expects these server variables to be present during bootstrap.
Second, we use $_ENV['PANTHEON_ENVIRONMENT'] and $_ENV['PANTHEON_SITE_NAME']. Pantheon injects several environment variables into every Quicksilver execution context. These are reliable and should be used instead of hardcoded values.
Third, the transient cleanup uses a direct database query. This is intentional. For sites with thousands of transients, the WordPress delete_transient() function is painfully slow because it fires individual queries and hooks for each one. A bulk delete is dramatically faster and perfectly safe for transient data.
Database Migration Script
WordPress does not have a built-in migration framework like Laravel or Rails. Most WordPress teams rely on plugins like WP Migrate DB or custom scripts. Here is a Quicksilver script that runs pending migrations stored as numbered PHP files:
<?php
// private/scripts/db_migrate.php
// Runs numbered migration files after deploy.
echo "Checking for pending database migrations...n";
$_SERVER['REQUEST_URI'] = '/';
$_SERVER['HTTP_HOST'] = $_ENV['PANTHEON_ENVIRONMENT'] . '-' . $_ENV['PANTHEON_SITE_NAME'] . '.pantheonsite.io';
require_once $_SERVER['DOCUMENT_ROOT'] . '/wp-load.php';
$migrations_dir = ABSPATH . 'private/migrations/';
$option_key = 'wpkite_last_migration';
if (!is_dir($migrations_dir)) {
echo "No migrations directory found. Skipping.n";
exit(0);
}
$last_migration = (int) get_option($option_key, 0);
$migration_files = glob($migrations_dir . '*.php');
sort($migration_files);
$executed = 0;
foreach ($migration_files as $file) {
$filename = basename($file);
preg_match('/^(d+)_/', $filename, $matches);
if (empty($matches[1])) {
echo "Skipping {$filename}: no numeric prefix found.n";
continue;
}
$migration_number = (int) $matches[1];
if ($migration_number <= $last_migration) {
continue;
}
echo "Running migration {$filename}...n";
try {
require $file;
update_option($option_key, $migration_number);
$executed++;
echo "Migration {$filename} completed successfully.n";
} catch (Exception $e) {
echo "Migration {$filename} FAILED: " . $e->getMessage() . "n";
// Do not update the option so this migration retries on next deploy
break;
}
}
if ($executed === 0) {
echo "No pending migrations.n";
} else {
echo "Executed {$executed} migration(s).n";
}
This pattern stores the last-run migration number in the WordPress options table. Each migration file is prefixed with a sequential number (like 001_add_custom_table.php, 002_update_meta_keys.php). On every deploy, the script checks which migrations have not yet run and executes them in order.
The break on failure is deliberate. If migration 003 fails, you do not want migration 004 to run against an inconsistent database state.
Slack Notification Script
Deploy notifications keep the whole team informed. Here is a Quicksilver script that posts a detailed message to a Slack channel:
<?php
// private/scripts/slack_notify.php
// Posts deploy notification to Slack via incoming webhook.
$secrets_file = $_SERVER['HOME'] . '/files/private/secrets.json';
if (!file_exists($secrets_file)) {
echo "Secrets file not found. Skipping Slack notification.n";
exit(0);
}
$secrets = json_decode(file_get_contents($secrets_file), true);
if (empty($secrets['slack_webhook_url'])) {
echo "Slack webhook URL not configured. Skipping.n";
exit(0);
}
$webhook_url = $secrets['slack_webhook_url'];
// Gather environment details from Quicksilver variables
$environment = $_ENV['PANTHEON_ENVIRONMENT'];
$site_name = $_ENV['PANTHEON_SITE_NAME'];
// The $_POST superglobal contains workflow-specific data
$deploy_message = isset($_POST['deploy_message']) ? $_POST['deploy_message'] : 'No deploy message provided';
$user_email = isset($_POST['user_email']) ? $_POST['user_email'] : 'Unknown';
$env_emoji = [
'dev' => ':construction:',
'test' => ':mag:',
'live' => ':rocket:',
];
$emoji = isset($env_emoji[$environment]) ? $env_emoji[$environment] : ':gear:';
$payload = [
'text' => "{$emoji} *Deploy to {$environment}* on *{$site_name}*",
'attachments' => [
[
'color' => ($environment === 'live') ? '#cc0000' : '#36a64f',
'fields' => [
[
'title' => 'Environment',
'value' => strtoupper($environment),
'short' => true,
],
[
'title' => 'Deployed By',
'value' => $user_email,
'short' => true,
],
[
'title' => 'Message',
'value' => $deploy_message,
'short' => false,
],
],
'footer' => 'Pantheon Quicksilver',
'ts' => time(),
],
],
];
$ch = curl_init($webhook_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code === 200) {
echo "Slack notification sent successfully.n";
} else {
echo "Slack notification failed. HTTP {$http_code}. Response: {$response}n";
}
Notice the use of $_POST variables. Pantheon populates the $_POST superglobal with metadata about the triggering workflow event. For deploy events, this includes deploy_message (the commit message or deploy note), user_email (who triggered the deploy), and other contextual data. This is a Quicksilver-specific behavior; these are not actual HTTP POST requests.
Also notice the 15-second curl timeout. Quicksilver scripts have a hard 120-second execution limit. If your Slack webhook is slow or the endpoint is down, you do not want the script hanging for the full timeout. Always set explicit timeouts on outbound HTTP requests.
Secrets Management with Quicksilver
Hardcoding API keys, webhook URLs, or database credentials in your Quicksilver scripts is a terrible idea. Those scripts live in your Git repository, and anyone with repo access would see your secrets.
Pantheon provides a dedicated location for secrets: the /files/private/ directory. This directory exists on the server filesystem but is not part of your Git repository and is not accessible via HTTP. It is the correct place to store sensitive configuration that Quicksilver scripts need at runtime.
Setting Up the Secrets File
The standard convention is a JSON file at /files/private/secrets.json. You create and manage this file using Terminus, Pantheon’s CLI tool.
First, create the directory and file:
# Connect to the site via Terminus and create the secrets file
terminus connection:info my-site.dev --field=sftp_command
# Use the SFTP connection to upload your secrets file
# Or use Terminus rsync:
terminus rsync my-site.dev:files/private/ ./local-private/ -- --dry-run
# Create a local secrets.json
cat > secrets.json << 'EOF'
{
"slack_webhook_url": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX",
"new_relic_api_key": "NRAK-XXXXXXXXXXXXXXXXXXXX",
"github_token": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"admin_notification_email": "[email protected]",
"visual_regression_api_key": "vr_xxxxxxxxxxxxxxxxxx"
}
EOF
# Upload via SFTP or rsync
terminus rsync . my-site.dev:files/private/ -- --include='secrets.json' --exclude='*'
Accessing Secrets in Scripts
Every Quicksilver script accesses secrets the same way:
<?php
$secrets_file = $_SERVER['HOME'] . '/files/private/secrets.json';
if (!file_exists($secrets_file)) {
die("FATAL: Secrets file not found at {$secrets_file}n");
}
$secrets = json_decode(file_get_contents($secrets_file), true);
if (json_last_error() !== JSON_ERROR_NONE) {
die("FATAL: Secrets file contains invalid JSON: " . json_last_error_msg() . "n");
}
// Now use $secrets['slack_webhook_url'], $secrets['new_relic_api_key'], etc.
Always validate that the file exists and that the JSON parses correctly. A malformed secrets file will cause silent failures in every Quicksilver script that depends on it.
Per-Environment Secrets
Some teams need different secrets for different environments (different Slack channels for Dev vs. Live, for example). You can handle this with environment-keyed JSON:
{
"dev": {
"slack_webhook_url": "https://hooks.slack.com/services/T000/B000/dev-channel",
"debug_mode": true
},
"test": {
"slack_webhook_url": "https://hooks.slack.com/services/T000/B000/staging-channel",
"debug_mode": true
},
"live": {
"slack_webhook_url": "https://hooks.slack.com/services/T000/B000/production-channel",
"debug_mode": false
}
}
Then in your scripts:
<?php
$secrets = json_decode(file_get_contents($_SERVER['HOME'] . '/files/private/secrets.json'), true);
$env = $_ENV['PANTHEON_ENVIRONMENT'];
$env_secrets = isset($secrets[$env]) ? $secrets[$env] : $secrets['dev'];
$webhook_url = $env_secrets['slack_webhook_url'];
One important caveat: the /files/private/ directory is environment-specific on Pantheon. When you clone files from Live to Dev, the secrets file comes along with it. If your Live and Dev secrets should be different, you need to re-upload the Dev secrets after every file clone. This is a perfect use case for the clone_files Quicksilver hook.
Combining Quicksilver with Terminus CLI
Terminus is Pantheon’s command-line interface, and it pairs powerfully with Quicksilver for multi-site automation. While Quicksilver handles event-driven automation on individual sites, Terminus lets you orchestrate workflows across your entire Pantheon portfolio.
Terminus Basics for WordPress
If you have not installed Terminus, do so first:
# Install Terminus via Homebrew (macOS)
brew install pantheon-systems/external/terminus
# Or install via curl
curl -O https://raw.githubusercontent.com/pantheon-systems/terminus-installer/master/builds/installer.phar
php installer.phar install
# Authenticate
terminus auth:login --machine-token=YOUR_MACHINE_TOKEN
Common Terminus commands for WordPress management:
# List all sites
terminus site:list
# Check environment info
terminus env:info my-site.live
# Deploy from test to live
terminus env:deploy my-site.live --note="Release 2.4.1: new checkout flow"
# Clear caches
terminus env:clear-cache my-site.live
# Run WP-CLI commands remotely
terminus wp my-site.live -- cache flush
terminus wp my-site.live -- plugin list --status=active
terminus wp my-site.live -- db query "SELECT COUNT(*) FROM wp_posts WHERE post_status='publish'"
# Clone database from live to dev
terminus env:clone-content my-site.live dev --db-only --yes
# Create a backup
terminus backup:create my-site.live --element=all
Multi-Site Deployment Script
For agencies managing dozens of WordPress sites on Pantheon, you can build Terminus-based scripts that work alongside Quicksilver. Here is a Bash script that deploys a plugin update across multiple sites:
#!/bin/bash
# deploy_plugin_update.sh
# Deploys a plugin update across all client sites on Pantheon.
SITES=("client-alpha" "client-beta" "client-gamma" "client-delta")
DEPLOY_NOTE="Security update: WooCommerce 7.4.1"
for site in "${SITES[@]}"; do
echo "=========================================="
echo "Processing: ${site}"
echo "=========================================="
# Deploy to test first
echo "Deploying to test environment..."
terminus env:deploy "${site}.test" --note="${DEPLOY_NOTE}" --yes 2>&1
if [ $? -ne 0 ]; then
echo "ERROR: Deploy to test failed for ${site}. Skipping."
continue
fi
# Run smoke test on test environment
echo "Running smoke test on test..."
HTTP_STATUS=$(terminus wp "${site}.test" -- eval 'echo http_response_code();' 2>/dev/null)
# Check site health
SITE_URL=$(terminus env:info "${site}.test" --field=domain 2>/dev/null)
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "https://${SITE_URL}/")
if [ "${RESPONSE}" != "200" ]; then
echo "WARNING: Test environment returned HTTP ${RESPONSE} for ${site}. Manual review required."
continue
fi
echo "Test environment healthy (HTTP 200). Deploying to live..."
terminus env:deploy "${site}.live" --note="${DEPLOY_NOTE}" --yes 2>&1
if [ $? -eq 0 ]; then
echo "SUCCESS: ${site} deployed to live."
terminus env:clear-cache "${site}.live" 2>&1
else
echo "ERROR: Deploy to live failed for ${site}."
fi
echo ""
done
echo "Batch deployment complete."
When this script deploys to each environment, the Quicksilver hooks defined in each site’s pantheon.yml fire automatically. So if every site has a Slack notification hook, the team gets notified for each deployment without the batch script needing to handle notifications itself.
Terminus Plugins for Extended Automation
Several Terminus plugins extend its capabilities:
# Install the Mass Run plugin for executing commands across multiple sites
terminus self:plugin:install terminus-mass-run
# Run a WP-CLI command across all sites
terminus mass-run wp -- plugin update --all
# Install the Secrets Manager plugin
terminus self:plugin:install terminus-secrets-manager-plugin
# Set secrets via Terminus instead of SFTP
terminus secret:set my-site slack_webhook_url "https://hooks.slack.com/services/..."
The terminus-secrets-manager-plugin is particularly valuable because it provides a cleaner workflow than manually uploading JSON files via SFTP. It stores secrets in Pantheon’s integrated secrets backend, and Quicksilver scripts can access them through the same file-based interface.
Common Pitfalls and How to Avoid Them
After working with Quicksilver across hundreds of WordPress deployments, certain failure patterns emerge repeatedly. Here are the most common problems and their solutions.
Pitfall 1: Confusing deploy and sync_code
This is the single most common mistake. A developer writes a Quicksilver script, attaches it to the deploy hook, pushes code to Dev, and wonders why it did not fire. The reason: pushing code to Dev triggers sync_code, not deploy. The deploy hook only fires when promoting code through the Pantheon Dashboard or Terminus.
The fix: if you need a script to run on both code pushes to Dev and promotions to Test/Live, add it to both hooks:
workflows:
deploy:
after:
- type: webphp
description: Flush caches
script: private/scripts/clear_cache.php
sync_code:
after:
- type: webphp
description: Flush caches
script: private/scripts/clear_cache.php
Pitfall 2: Exceeding the 120-Second Timeout
Quicksilver enforces a strict 120-second timeout per script. If your script runs longer, Pantheon kills it without warning. The script’s output up to that point is lost, and no error message is generated beyond a generic timeout notice in the workflow log.
This hits WordPress teams hardest during database migrations. A migration that adds an index to a table with millions of rows can easily exceed 120 seconds. The same goes for scripts that make dozens of outbound HTTP requests sequentially.
Solutions:
Break large operations into multiple smaller scripts. Each gets its own 120-second window:
workflows:
deploy:
after:
- type: webphp
description: Migration batch 1
script: private/scripts/migrate_batch_1.php
- type: webphp
description: Migration batch 2
script: private/scripts/migrate_batch_2.php
For outbound HTTP requests, use non-blocking calls or parallel execution with curl_multi_exec:
<?php
// Send multiple webhook notifications in parallel
$urls = [
'https://hooks.slack.com/services/...',
'https://api.newrelic.com/v2/...',
'https://api.pagerduty.com/...',
];
$multi_handle = curl_multi_init();
$handles = [];
foreach ($urls as $url) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['status' => 'deployed']));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_multi_add_handle($multi_handle, $ch);
$handles[] = $ch;
}
$running = null;
do {
curl_multi_exec($multi_handle, $running);
curl_multi_select($multi_handle);
} while ($running > 0);
foreach ($handles as $ch) {
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
echo "Notified {$url}: HTTP {$http_code}n";
curl_multi_remove_handle($multi_handle, $ch);
curl_close($ch);
}
curl_multi_close($multi_handle);
Pitfall 3: WordPress Bootstrap Failures
Requiring wp-load.php in a Quicksilver script can fail for subtle reasons. The most common cause is missing server variables. WordPress and many plugins check $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI'], and $_SERVER['SERVER_NAME'] during bootstrap. If these are not set, you get fatal errors or incorrect behavior.
Always set these before loading WordPress:
<?php
// Required for WordPress bootstrap in Quicksilver context
$_SERVER['REQUEST_URI'] = '/';
$_SERVER['HTTP_HOST'] = $_ENV['PANTHEON_ENVIRONMENT'] . '-' . $_ENV['PANTHEON_SITE_NAME'] . '.pantheonsite.io';
$_SERVER['SERVER_NAME'] = $_SERVER['HTTP_HOST'];
$_SERVER['HTTPS'] = 'on';
// Prevent WordPress from sending headers
define('WP_USE_THEMES', false);
require_once $_SERVER['DOCUMENT_ROOT'] . '/wp-load.php';
Setting WP_USE_THEMES to false prevents WordPress from loading the theme template hierarchy, which speeds up bootstrap time significantly and avoids theme-related errors in the non-HTTP context.
Pitfall 4: Scripts Not Found
A “script not found” error usually means one of two things: the file path in pantheon.yml is wrong, or the script file was not committed to Git.
Remember that Quicksilver scripts must be in your Git repository. The private/ directory in your repo maps to the private/ directory on the server. Double-check:
# Verify the script is tracked by Git
git ls-files private/scripts/
# Should output something like:
# private/scripts/clear_cache.php
# private/scripts/db_migrate.php
# private/scripts/slack_notify.php
Also verify that the path in pantheon.yml matches exactly. Paths are case-sensitive on Pantheon’s Linux-based infrastructure.
Pitfall 5: Assuming Scripts Run as WP-CLI
Quicksilver scripts are not WP-CLI commands. They are PHP scripts executed by the web server. This means:
You cannot use WP_CLI::log() or other WP-CLI classes unless you explicitly load them. Output goes to the Quicksilver execution log, not stdout. The script runs as the web server user, not as a shell user. Functions that depend on a TTY (like interactive prompts) will fail.
If you need WP-CLI functionality in a Quicksilver script, you can shell out to it:
<?php
$output = shell_exec('wp cache flush --path=' . $_SERVER['DOCUMENT_ROOT'] . ' 2>&1');
echo $output;
However, this approach has limitations. The shell environment inside Quicksilver may not have WP-CLI in the PATH. Use the full path to the WP-CLI binary if needed:
<?php
$wp_cli = '/usr/local/bin/wp';
$docroot = $_SERVER['DOCUMENT_ROOT'];
$output = shell_exec("{$wp_cli} cache flush --path={$docroot} 2>&1");
echo "WP-CLI output: {$output}n";
Real-World Workflow Examples
Theory only gets you so far. Here are complete, production-tested workflow configurations and their corresponding scripts.
Example 1: Full Agency Deploy Pipeline
This pantheon.yml configuration is what we use at agencies managing 20+ WordPress sites. It handles cache clearing, migrations, notifications, and monitoring in a single deploy workflow:
api_version: 1
php_version: 8.2
workflows:
deploy:
after:
- type: webphp
description: Clear all caches
script: private/scripts/clear_cache.php
- type: webphp
description: Run pending database migrations
script: private/scripts/db_migrate.php
- type: webphp
description: Record deployment in New Relic
script: private/scripts/new_relic_deploy.php
- type: webphp
description: Notify team via Slack
script: private/scripts/slack_notify.php
- type: webphp
description: Warm critical page caches
script: private/scripts/cache_warm.php
sync_code:
after:
- type: webphp
description: Clear OPcache on dev
script: private/scripts/clear_opcache.php
- type: webphp
description: Notify dev channel
script: private/scripts/slack_notify_dev.php
clone_database:
after:
- type: webphp
description: Sanitize cloned database
script: private/scripts/sanitize_db.php
- type: webphp
description: Replace URLs in database
script: private/scripts/search_replace_urls.php
- type: webphp
description: Reset admin passwords
script: private/scripts/reset_passwords.php
New Relic Deploy Marker Script
Recording deployments in New Relic gives you performance baselines. When response times spike after a deploy, you can immediately correlate the regression with the specific code change:
<?php
// private/scripts/new_relic_deploy.php
// Records a deployment marker in New Relic APM.
$secrets_file = $_SERVER['HOME'] . '/files/private/secrets.json';
if (!file_exists($secrets_file)) {
echo "Secrets file not found. Skipping New Relic marker.n";
exit(0);
}
$secrets = json_decode(file_get_contents($secrets_file), true);
if (empty($secrets['new_relic_api_key']) || empty($secrets['new_relic_app_id'])) {
echo "New Relic credentials not configured. Skipping.n";
exit(0);
}
$api_key = $secrets['new_relic_api_key'];
$app_id = $secrets['new_relic_app_id'];
$user = isset($_POST['user_email']) ? $_POST['user_email'] : 'quicksilver';
$description = isset($_POST['deploy_message']) ? $_POST['deploy_message'] : 'Automated deploy';
$revision = isset($_POST['deploy_tag']) ? $_POST['deploy_tag'] : 'unknown';
$payload = json_encode([
'deployment' => [
'revision' => $revision,
'description' => $description,
'user' => $user,
],
]);
$ch = curl_init("https://api.newrelic.com/v2/applications/{$app_id}/deployments.json");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
"X-Api-Key: {$api_key}",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code === 201) {
echo "New Relic deployment marker recorded successfully.n";
} else {
echo "New Relic API returned HTTP {$http_code}. Response: {$response}n";
}
Cache Warming Script
After clearing caches on deploy, your site’s first visitors will hit uncached pages. For high-traffic WordPress sites, this cold-cache period causes noticeable latency spikes. A cache warming script pre-populates the cache for your most important pages:
<?php
// private/scripts/cache_warm.php
// Warms caches for critical pages after deploy.
$environment = $_ENV['PANTHEON_ENVIRONMENT'];
$site_name = $_ENV['PANTHEON_SITE_NAME'];
// Only warm caches on live and test
if (!in_array($environment, ['live', 'test'])) {
echo "Skipping cache warm on {$environment} environment.n";
exit(0);
}
$base_url = "https://{$environment}-{$site_name}.pantheonsite.io";
// If we have a custom domain on live, use it
if ($environment === 'live') {
// Bootstrap WordPress to get the site URL
$_SERVER['REQUEST_URI'] = '/';
$_SERVER['HTTP_HOST'] = "{$environment}-{$site_name}.pantheonsite.io";
define('WP_USE_THEMES', false);
require_once $_SERVER['DOCUMENT_ROOT'] . '/wp-load.php';
$base_url = get_option('siteurl');
}
$paths = [
'/',
'/services/',
'/pricing/',
'/about/',
'/contact/',
'/blog/',
'/support/',
];
$multi_handle = curl_multi_init();
$handles = [];
foreach ($paths as $path) {
$url = $base_url . $path;
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_USERAGENT, 'Pantheon Quicksilver Cache Warmer');
curl_multi_add_handle($multi_handle, $ch);
$handles[$url] = $ch;
}
$running = null;
do {
curl_multi_exec($multi_handle, $running);
curl_multi_select($multi_handle);
} while ($running > 0);
foreach ($handles as $url => $ch) {
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$total_time = round(curl_getinfo($ch, CURLINFO_TOTAL_TIME), 2);
echo "Warmed {$url}: HTTP {$http_code} ({$total_time}s)n";
curl_multi_remove_handle($multi_handle, $ch);
curl_close($ch);
}
curl_multi_close($multi_handle);
echo "Cache warming complete. Warmed " . count($paths) . " URLs.n";
This script uses curl_multi_exec to request all URLs in parallel rather than sequentially, which is critical for staying under the 120-second timeout. For a site with 7 pages to warm, sequential requests at 2 seconds each would take 14 seconds. Parallel requests complete in roughly the time of the slowest single request.
Example 2: Database Sanitization After Clone
When you clone the Live database to Dev for local development, you probably do not want real customer email addresses, real payment data, or production API keys sitting in your Dev environment. This sanitization script runs automatically after every database clone:
<?php
// private/scripts/sanitize_db.php
// Sanitizes sensitive data after database clone to non-live environments.
$environment = $_ENV['PANTHEON_ENVIRONMENT'];
// Never run on live (safety check)
if ($environment === 'live') {
echo "Refusing to sanitize live database.n";
exit(0);
}
$_SERVER['REQUEST_URI'] = '/';
$_SERVER['HTTP_HOST'] = $_ENV['PANTHEON_ENVIRONMENT'] . '-' . $_ENV['PANTHEON_SITE_NAME'] . '.pantheonsite.io';
define('WP_USE_THEMES', false);
require_once $_SERVER['DOCUMENT_ROOT'] . '/wp-load.php';
global $wpdb;
// Anonymize user email addresses (except admin accounts)
$anonymized_users = $wpdb->query("
UPDATE {$wpdb->users}
SET user_email = CONCAT('user', ID, '@example.dev'),
display_name = CONCAT('Test User ', ID)
WHERE ID NOT IN (
SELECT user_id FROM {$wpdb->usermeta}
WHERE meta_key = '{$wpdb->prefix}capabilities'
AND meta_value LIKE '%administrator%'
)
");
echo "Anonymized {$anonymized_users} user accounts.n";
// Reset all non-admin passwords to a standard dev password
$dev_password = wp_hash_password('devpassword123');
$reset_passwords = $wpdb->query(
$wpdb->prepare("
UPDATE {$wpdb->users}
SET user_pass = %s
WHERE ID NOT IN (
SELECT user_id FROM {$wpdb->usermeta}
WHERE meta_key = '{$wpdb->prefix}capabilities'
AND meta_value LIKE '%administrator%'
)
", $dev_password)
);
echo "Reset {$reset_passwords} user passwords.n";
// Remove WooCommerce order personal data if WooCommerce is active
if ($wpdb->get_var("SHOW TABLES LIKE '{$wpdb->prefix}wc_orders'") === "{$wpdb->prefix}wc_orders") {
$wpdb->query("
UPDATE {$wpdb->prefix}wc_orders
SET billing_email = CONCAT('order', id, '@example.dev'),
billing_first_name = 'Test',
billing_last_name = CONCAT('Customer ', id),
billing_phone = '555-0100',
shipping_first_name = 'Test',
shipping_last_name = CONCAT('Customer ', id)
");
echo "Sanitized WooCommerce order data.n";
}
// Disable production-only plugins
$production_plugins = [
'google-analytics-for-wordpress/googleanalytics.php',
'wp-mail-smtp/wp_mail_smtp.php',
'wordfence/wordfence.php',
];
$active_plugins = get_option('active_plugins', []);
$deactivated = 0;
foreach ($production_plugins as $plugin) {
$key = array_search($plugin, $active_plugins);
if ($key !== false) {
unset($active_plugins[$key]);
$deactivated++;
}
}
if ($deactivated > 0) {
update_option('active_plugins', array_values($active_plugins));
echo "Deactivated {$deactivated} production-only plugins.n";
}
// Update site URL options
$dev_url = "https://{$_ENV['PANTHEON_ENVIRONMENT']}-{$_ENV['PANTHEON_SITE_NAME']}.pantheonsite.io";
update_option('siteurl', $dev_url);
update_option('home', $dev_url);
echo "Updated site URL to {$dev_url}.n";
echo "Database sanitization complete.n";
This script is a significant time saver. Without it, every developer who clones the Live database has to manually run through a checklist of sanitization steps. With Quicksilver, it happens automatically, every time, without fail.
Visual Regression Testing Triggers
One of the most powerful uses of Quicksilver hooks is triggering visual regression tests after deployments to Test or Live. Visual regression testing compares screenshots of your site before and after a code change, flagging any unintended visual differences.
Several services integrate well with Quicksilver: Percy (by BrowserStack), BackstopJS (self-hosted), and Chromatic. Here is a script that triggers a Percy build after deployment:
<?php
// private/scripts/trigger_visual_test.php
// Triggers a Percy visual regression test build via their API.
$environment = $_ENV['PANTHEON_ENVIRONMENT'];
// Only run visual tests on test environment deploys
if ($environment !== 'test') {
echo "Skipping visual regression tests on {$environment}.n";
exit(0);
}
$secrets_file = $_SERVER['HOME'] . '/files/private/secrets.json';
if (!file_exists($secrets_file)) {
echo "Secrets file not found. Cannot trigger visual tests.n";
exit(1);
}
$secrets = json_decode(file_get_contents($secrets_file), true);
if (empty($secrets['percy_token']) || empty($secrets['github_token'])) {
echo "Percy or GitHub token not configured. Skipping.n";
exit(0);
}
$site_name = $_ENV['PANTHEON_SITE_NAME'];
$deploy_tag = isset($_POST['deploy_tag']) ? $_POST['deploy_tag'] : 'unknown';
// Trigger a CI/CD pipeline that runs Percy snapshots
// This example uses GitHub Actions workflow dispatch
$github_token = $secrets['github_token'];
$repo = $secrets['github_repo']; // e.g., "my-org/my-site"
$payload = json_encode([
'ref' => 'main',
'inputs' => [
'environment' => $environment,
'site_name' => $site_name,
'deploy_tag' => $deploy_tag,
'test_url' => "https://test-{$site_name}.pantheonsite.io",
],
]);
$ch = curl_init("https://api.github.com/repos/{$repo}/actions/workflows/visual-regression.yml/dispatches");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: application/vnd.github.v3+json',
"Authorization: token {$github_token}",
'Content-Type: application/json',
'User-Agent: Pantheon-Quicksilver',
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code === 204) {
echo "Visual regression test triggered successfully.n";
echo "GitHub Actions workflow dispatched for {$site_name} on {$environment}.n";
} else {
echo "Failed to trigger visual regression test. HTTP {$http_code}.n";
echo "Response: {$response}n";
}
The corresponding GitHub Actions workflow (.github/workflows/visual-regression.yml) would look something like this:
name: Visual Regression Tests
on:
workflow_dispatch:
inputs:
environment:
description: 'Pantheon environment'
required: true
site_name:
description: 'Pantheon site name'
required: true
deploy_tag:
description: 'Deploy tag/version'
required: true
test_url:
description: 'URL to test against'
required: true
jobs:
visual-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Run Percy snapshots
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
TEST_URL: ${{ github.event.inputs.test_url }}
run: npx percy snapshot snapshots.yml
This creates a fully automated visual QA pipeline: deploy to Test, Quicksilver triggers Percy, Percy captures screenshots and compares them against the baseline, and the team reviews any visual diffs before promoting to Live. No manual screenshot comparisons. No “it looked fine on my machine” arguments.
For teams that prefer self-hosted solutions, you can trigger BackstopJS tests instead:
<?php
// private/scripts/trigger_backstop.php
// Triggers a BackstopJS visual regression test on a remote CI server.
$environment = $_ENV['PANTHEON_ENVIRONMENT'];
if ($environment !== 'test') {
exit(0);
}
$secrets = json_decode(
file_get_contents($_SERVER['HOME'] . '/files/private/secrets.json'),
true
);
$ci_webhook = $secrets['backstop_ci_webhook'];
$site_name = $_ENV['PANTHEON_SITE_NAME'];
$payload = json_encode([
'reference_url' => "https://live-{$site_name}.pantheonsite.io",
'test_url' => "https://test-{$site_name}.pantheonsite.io",
'scenarios' => ['homepage', 'blog', 'contact', 'pricing', 'checkout'],
'viewports' => [
['label' => 'phone', 'width' => 375, 'height' => 812],
['label' => 'tablet', 'width' => 768, 'height' => 1024],
['label' => 'desktop', 'width' => 1440, 'height' => 900],
],
]);
$ch = curl_init($ci_webhook);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo "BackstopJS trigger: HTTP {$http_code}n";
Debugging Quicksilver Scripts
When a Quicksilver script fails, debugging can be frustrating. The execution environment is ephemeral, you cannot SSH in and run the script manually, and error messages are often cryptic. Here is a systematic approach to debugging.
Check the Workflow Log
Every Quicksilver execution is logged in the Pantheon Dashboard. Navigate to your site, select the environment, and click the “Workflows” tab. Each workflow entry shows which scripts ran, their output, and whether they succeeded or failed.
You can also retrieve workflow logs via Terminus:
# List recent workflows for a site environment
terminus workflow:list my-site --fields=id,env,workflow,status,started_at
# View details of a specific workflow
terminus workflow:info:logs my-site --id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Add Verbose Logging to Scripts
Quicksilver captures all echo and print output from your scripts and displays it in the workflow log. Use this liberally during development:
<?php
// Verbose debugging version of a Quicksilver script
echo "=== Script Start ===n";
echo "Environment: " . $_ENV['PANTHEON_ENVIRONMENT'] . "n";
echo "Site Name: " . $_ENV['PANTHEON_SITE_NAME'] . "n";
echo "Document Root: " . $_SERVER['DOCUMENT_ROOT'] . "n";
echo "HOME: " . $_SERVER['HOME'] . "n";
echo "PHP Version: " . PHP_VERSION . "n";
echo "Memory Limit: " . ini_get('memory_limit') . "n";
echo "Max Execution Time: " . ini_get('max_execution_time') . "n";
echo "n";
echo "POST data:n";
print_r($_POST);
echo "n";
echo "ENV variables:n";
foreach ($_ENV as $key => $value) {
if (strpos($key, 'PANTHEON') !== false) {
echo " {$key}: {$value}n";
}
}
echo "n";
// Check if secrets file exists
$secrets_path = $_SERVER['HOME'] . '/files/private/secrets.json';
echo "Secrets file exists: " . (file_exists($secrets_path) ? 'YES' : 'NO') . "n";
if (file_exists($secrets_path)) {
$secrets = json_decode(file_get_contents($secrets_path), true);
echo "Secrets keys: " . implode(', ', array_keys($secrets)) . "n";
echo "JSON valid: " . (json_last_error() === JSON_ERROR_NONE ? 'YES' : 'NO') . "n";
}
echo "n=== Script End ===n";
Deploy this debug script temporarily to diagnose environment issues. Once you have identified the problem, replace it with the production version.
Test Scripts Locally with Terminus
You can simulate Quicksilver execution by running your PHP scripts via Terminus on the remote environment:
# Execute a PHP file on the Pantheon environment
terminus wp my-site.dev -- eval-file private/scripts/clear_cache.php
# Or use a direct PHP execution
terminus remote:drush my-site.dev -- php-script private/scripts/clear_cache.php
This does not perfectly replicate the Quicksilver execution context (the $_POST variables will not be populated), but it catches most syntax errors and logic problems.
Common Error Patterns
“Script not found”: The file path in pantheon.yml does not match the actual file location in Git. Check case sensitivity and directory structure.
“PHP Fatal error: Allowed memory size exhausted”: Your script or the WordPress bootstrap is consuming too much memory. Quicksilver scripts inherit the site’s PHP memory limit (typically 256MB on Pantheon). If you are processing large datasets, batch your operations.
“Maximum execution time exceeded”: Your script hit the 120-second wall. Profile your script to find the bottleneck. Usually it is a slow database query or a blocking HTTP request without a timeout.
Empty output, no errors: The script probably threw a fatal error that was caught by PHP’s error handling before any output was generated. Add error_reporting(E_ALL); and ini_set('display_errors', '1'); at the top of your script temporarily.
Script runs but nothing happens: Check your conditional logic. A common pattern is environment checks (if ($environment !== 'live')) that silently exit. Add echo statements before every exit point.
Using error_log for Persistent Debugging
For issues that only reproduce during actual Quicksilver execution (not during Terminus testing), use error_log() to write to the PHP error log:
<?php
error_log('[Quicksilver] Starting cache clear script');
error_log('[Quicksilver] Environment: ' . $_ENV['PANTHEON_ENVIRONMENT']);
// Your script logic here
error_log('[Quicksilver] Script completed successfully');
Then check the PHP error log via Terminus:
# View recent log entries
terminus logs:show my-site.dev --channel=php
Advanced Patterns: Multidev and Feature Branch Workflows
Pantheon’s Multidev feature creates isolated environments for Git feature branches. Each Multidev environment gets its own URL, database, and filesystem. Quicksilver hooks fire on Multidev environments just like they do on Dev, Test, and Live.
This opens up powerful branching workflows for WordPress teams. Here is a pantheon.yml configuration that handles Multidev-specific automation:
api_version: 1
php_version: 8.2
workflows:
create_cloud_development_environment:
after:
- type: webphp
description: Configure new Multidev environment
script: private/scripts/configure_multidev.php
deploy:
after:
- type: webphp
description: Post-deploy tasks
script: private/scripts/post_deploy.php
sync_code:
after:
- type: webphp
description: Post-sync tasks
script: private/scripts/post_sync.php
The Multidev configuration script:
<?php
// private/scripts/configure_multidev.php
// Sets up a new Multidev environment with appropriate defaults.
$environment = $_ENV['PANTHEON_ENVIRONMENT'];
$site_name = $_ENV['PANTHEON_SITE_NAME'];
echo "Configuring new Multidev environment: {$environment}n";
// Bootstrap WordPress
$_SERVER['REQUEST_URI'] = '/';
$_SERVER['HTTP_HOST'] = "{$environment}-{$site_name}.pantheonsite.io";
$_SERVER['HTTPS'] = 'on';
define('WP_USE_THEMES', false);
require_once $_SERVER['DOCUMENT_ROOT'] . '/wp-load.php';
// Update site URL for the new environment
$multidev_url = "https://{$environment}-{$site_name}.pantheonsite.io";
update_option('siteurl', $multidev_url);
update_option('home', $multidev_url);
echo "Site URL set to {$multidev_url}n";
// Enable debug mode on Multidev environments
// (Assumes wp-config.php checks for this option)
update_option('wpkite_debug_mode', true);
echo "Debug mode enabled.n";
// Disable caching plugins that interfere with development
$plugins_to_deactivate = [
'w3-total-cache/w3-total-cache.php',
'wp-super-cache/wp-cache.php',
'autoptimize/autoptimize.php',
];
$active_plugins = get_option('active_plugins', []);
$deactivated = 0;
foreach ($plugins_to_deactivate as $plugin) {
$key = array_search($plugin, $active_plugins);
if ($key !== false) {
unset($active_plugins[$key]);
$deactivated++;
}
}
if ($deactivated > 0) {
update_option('active_plugins', array_values($active_plugins));
echo "Deactivated {$deactivated} caching plugins.n";
}
// Notify the team about the new environment
$secrets_file = $_SERVER['HOME'] . '/files/private/secrets.json';
if (file_exists($secrets_file)) {
$secrets = json_decode(file_get_contents($secrets_file), true);
if (!empty($secrets['slack_webhook_url'])) {
$payload = json_encode([
'text' => ":seedling: New Multidev environment created: *{$environment}* on *{$site_name}*nURL: {$multidev_url}",
]);
$ch = curl_init($secrets['slack_webhook_url']);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_exec($ch);
curl_close($ch);
echo "Slack notification sent.n";
}
}
echo "Multidev configuration complete.n";
Performance Considerations and Optimization
Quicksilver scripts should be fast. Every second your script runs is a second added to the deployment workflow. For teams deploying multiple times per day, a slow post-deploy pipeline creates friction that discourages frequent releases.
Here are concrete optimization strategies.
Skip WordPress Bootstrap When Possible
Loading WordPress (wp-load.php) takes 200 to 500 milliseconds on a typical Pantheon environment. If your script only needs to make an HTTP request (like a Slack notification), skip the bootstrap entirely. The Slack notification script earlier in this article does not load WordPress at all; it only uses PHP’s built-in curl functions.
Use Parallel HTTP Requests
As demonstrated in the cache warming script, curl_multi_exec sends multiple HTTP requests simultaneously. For a script that needs to notify three different services, parallel requests take 2 seconds instead of 6 seconds (assuming each request takes about 2 seconds).
Batch Database Operations
Instead of running individual queries in a loop, use bulk SQL operations. The sanitization script demonstrates this with a single UPDATE query that modifies all non-admin users, rather than looping through each user individually.
A common anti-pattern:
// Slow: individual queries in a loop
$users = $wpdb->get_results("SELECT ID FROM {$wpdb->users}");
foreach ($users as $user) {
$wpdb->update(
$wpdb->users,
['user_email' => "user{$user->ID}@example.dev"],
['ID' => $user->ID]
);
}
The correct approach:
// Fast: single bulk query
$wpdb->query("
UPDATE {$wpdb->users}
SET user_email = CONCAT('user', ID, '@example.dev')
");
For a table with 10,000 users, the bulk approach runs in under 1 second. The loop approach takes 30 seconds or more.
Measure Script Execution Time
Add timing to your scripts so you can monitor performance in the workflow logs:
<?php
$start_time = microtime(true);
// ... your script logic ...
$elapsed = round(microtime(true) - $start_time, 2);
echo "Script completed in {$elapsed} seconds.n";
If a script consistently takes more than 30 seconds, it is a candidate for optimization or splitting into multiple scripts.
Security Best Practices
Quicksilver scripts have significant power over your WordPress installation. A compromised script could modify the database, exfiltrate secrets, or deface the site. Follow these practices to minimize risk.
Keep Scripts in the Private Directory
Always store Quicksilver scripts in the private/ directory. This directory is not web-accessible, so even if someone discovers a script URL, they cannot execute it via HTTP. Storing scripts in wp-content/ or another public directory is a security risk.
Validate All External Input
The $_POST data populated by Quicksilver comes from Pantheon’s platform, not from user input. But if your scripts read from other sources (the database, external APIs, the filesystem), validate and sanitize that data before using it in SQL queries or shell commands:
<?php
// Bad: direct interpolation
$table_name = $_POST['table'];
$wpdb->query("DROP TABLE {$table_name}");
// Good: whitelist validation
$allowed_tables = ['wp_wpkite_temp_cache', 'wp_wpkite_staging_data'];
$table_name = $_POST['table'];
if (!in_array($table_name, $allowed_tables, true)) {
die("Invalid table name: {$table_name}n");
}
$wpdb->query("DROP TABLE `{$table_name}`");
Rotate Secrets Regularly
The secrets.json file should contain tokens and keys that are rotated on a regular schedule. Set calendar reminders to rotate Slack webhook URLs, API keys, and other credentials at least every 90 days. Use the terminus secret:set command to make rotation easy.
Audit Script Changes
Since Quicksilver scripts live in Git, every change is tracked. Require code reviews for any changes to files in the private/scripts/ directory. A malicious or buggy change to a Quicksilver script can affect every future deployment.
Add a CODEOWNERS file to enforce review requirements:
# .github/CODEOWNERS
private/scripts/ @devops-team
pantheon.yml @devops-team
Putting It All Together: A Complete Production Setup
Let us assemble everything into a complete, production-ready Quicksilver configuration for a WordPress site on Pantheon. This represents the setup we would use for a high-traffic client site with an agency team.
Repository Structure
my-wordpress-site/
├── pantheon.yml
├── wp-config.php
├── wp-content/
│ ├── themes/
│ ├── plugins/
│ └── uploads/
├── private/
│ ├── scripts/
│ │ ├── clear_cache.php
│ │ ├── db_migrate.php
│ │ ├── slack_notify.php
│ │ ├── new_relic_deploy.php
│ │ ├── cache_warm.php
│ │ ├── sanitize_db.php
│ │ ├── search_replace_urls.php
│ │ ├── configure_multidev.php
│ │ ├── trigger_visual_test.php
│ │ └── debug_environment.php
│ └── migrations/
│ ├── 001_create_custom_tables.php
│ ├── 002_add_meta_indexes.php
│ └── 003_migrate_legacy_data.php
└── .github/
├── CODEOWNERS
└── workflows/
└── visual-regression.yml
Final pantheon.yml
api_version: 1
php_version: 8.2
workflows:
deploy:
after:
- type: webphp
description: Clear all caches
script: private/scripts/clear_cache.php
- type: webphp
description: Run pending database migrations
script: private/scripts/db_migrate.php
- type: webphp
description: Record deployment in New Relic
script: private/scripts/new_relic_deploy.php
- type: webphp
description: Trigger visual regression tests
script: private/scripts/trigger_visual_test.php
- type: webphp
description: Warm critical page caches
script: private/scripts/cache_warm.php
- type: webphp
description: Notify team via Slack
script: private/scripts/slack_notify.php
sync_code:
after:
- type: webphp
description: Clear OPcache
script: private/scripts/clear_cache.php
clear_cache:
after:
- type: webphp
description: Warm critical page caches
script: private/scripts/cache_warm.php
clone_database:
after:
- type: webphp
description: Sanitize cloned database
script: private/scripts/sanitize_db.php
- type: webphp
description: Update URLs for target environment
script: private/scripts/search_replace_urls.php
create_cloud_development_environment:
after:
- type: webphp
description: Configure new Multidev environment
script: private/scripts/configure_multidev.php
Notice the ordering in the deploy workflow. Caches clear first (so subsequent scripts see fresh state). Migrations run second (structural changes before any traffic). New Relic gets the deploy marker third. Visual regression tests trigger fourth (they need the new code and schema in place). Cache warming runs fifth (to pre-populate after the flush). Slack notification fires last, so if any prior step fails, the team sees the failure message.
Operational Checklist
Before going live with this setup, verify:
1. All scripts are committed to Git and appear in git ls-files private/scripts/.
2. The secrets.json file is uploaded to /files/private/ on every environment that needs it.
3. Each script has been tested individually via terminus wp eval-file.
4. The total execution time of all deploy scripts combined stays under 120 seconds (measure with timing output).
5. Slack webhook URLs are valid and posting to the correct channels.
6. New Relic API keys have the correct permissions for deployment markers.
7. GitHub tokens have the minimum required scopes (only repo and actions for workflow dispatch).
8. CODEOWNERS is configured so that changes to private/scripts/ require DevOps review.
Quicksilver vs. CI/CD: When to Use Which
A reasonable question: why use Quicksilver at all when you could run post-deploy scripts in your CI/CD pipeline (GitHub Actions, CircleCI, GitLab CI)?
The answer is scope and reliability. CI/CD pipelines run in external infrastructure. They need SSH keys or API tokens to reach your Pantheon environment. They can fail due to network issues, runner availability, or credential expiration. They add complexity to your build configuration.
Quicksilver runs inside Pantheon’s infrastructure, directly on the same server as your WordPress site. It has zero-latency access to your database, filesystem, and codebase. It requires no external credentials. It fires reliably on every platform event because it is a first-party feature of the hosting platform.
Use Quicksilver for: tasks that must run on every deploy, tasks that need direct database or filesystem access, notifications and logging, cache management, and environment configuration.
Use CI/CD for: running test suites before deploy (not after), building frontend assets, static analysis and linting, tasks that require tools not available on Pantheon’s servers (Node.js build tools, for example), and tasks that need more than 120 seconds.
The best setups use both. CI/CD handles pre-deploy validation (tests, linting, building). Quicksilver handles post-deploy automation (cache clearing, migrations, notifications). They complement each other rather than competing.
Summary of Key Points
Quicksilver is one of Pantheon’s most underused features. Many WordPress teams on Pantheon deploy manually, clear caches by clicking buttons, and notify teammates by typing in Slack. That workflow is fine for a single site with one developer. It breaks down completely at scale.
With the configuration and scripts in this article, you can automate the entire post-deploy lifecycle: cache clearing, database migrations, deployment tracking, visual regression testing, cache warming, and team notifications. Every deploy follows the same process. Every environment gets configured correctly. Every team member stays informed.
The setup takes about a day to implement from scratch. The time savings compound with every deploy. For a team deploying twice daily across 10 sites, that is 20 manual post-deploy checklists eliminated every day. Over a year, the math is overwhelming.
Start small. Add a Slack notification hook. Then add cache clearing. Then migrations. Build confidence with each addition. Within a few weeks, you will have a fully automated deployment pipeline that runs reliably without human intervention.
The scripts in this article are all available in Pantheon’s official Quicksilver examples repository on GitHub at pantheon-systems/quicksilver-examples. Fork it, customize the scripts for your WordPress setup, and start automating.
Tom Bradley
DevOps engineer focused on WordPress deployment automation. Builds CI/CD pipelines and infrastructure-as-code solutions for WordPress agencies.