Have Baby. Need Stuff! is a baby gear site that my wife and I just launched. I thought I’d share how I built it.

WordPress Core
WordPress is a Git submodule, with the content directory moved to the /content/
directory. This makes my Git repo smaller, as WordPress isn’t actually in it.
Underscores
For a theme base, I started with the Underscores starter theme by the theme team at Automattic. Underscores is not a theme itself… it’s a starting point for building your own theme.
Bootstrap
Next, I integrated Bootstrap, by Twitter, to handle the CSS base and the grid system. Bootstrap is a really powerful framework, and version 2.0 has great responsive design support, which allowed me to create a single design that scales up to big screens or down to tablet or phone screen sizes. Try resizing it in your browser to see the responsiveness in action!
The CSS for the site is authored using LESS, which plays well with Bootstrap. I’m compiling/minifying/concatenating the CSS and JS using CodeKit, an amazing Mac OS X app that makes development a breeze.
Typekit
For web fonts, it’s hard to beat Typekit.
Subtle Patterns
I needed some patterns to use on the site, but I was frustrated with the licensing terms on many pattern sites I was finding. And then I found Subtle Patterns. Gorgeous, subtle patterns, liberally licensed. And hey, their site is WordPress powered too!
Posts 2 Posts
The site has the concepts of Departments, Needs, and Products. Each Department has multiple Needs. Each Need has multiple Products. I used Scribu’s phenomenal Posts 2 Posts plugin to handle these relationships.

Here’s the basic Posts 2 Posts connection code:
<?php
function hbns_register_p2p_relationships() {
if ( !function_exists( 'p2p_register_connection_type' ) )
return;
// Connect Departments to Needs
p2p_register_connection_type( array(
'name' => 'departments_to_needs',
'from' => 'department',
'to' => 'need',
'sortable' => 'to',
'admin_box' => 'to',
'admin_column' => 'any',
'cardinality' => 'one-to-many',
) );
// Connect Needs to Products
p2p_register_connection_type( array(
'name' => 'needs_to_products',
'from' => 'need',
'to' => 'product',
'sortable' => 'from',
'admin_column' => 'any',
'admin_box' => array(
'show' => 'any',
'context' => 'advanced',
),
'cardinality' => 'many-to-many',
'fields' => array(
'description' => 'Description',
),
) );
}
add_action( 'wp_loaded', 'hbns_register_p2p_relationships' );
I created a Custom Post Type for each of Departments, Needs, and Products, and connected them all using Posts 2 Posts. The connection between a Need and a Product also contains description metadata, as seen here:

Since Posts 2 Posts was a required plugin for the site to function, I didn’t want there to be any possibility of accidental deactivation. So I wrote a quick mu-plugins
drop-in to “lock” certain plugins on.
<?php
class HBNS_Always_Active_Plugins {
static $instance;
private $always_active_plugins;
function __construct() {
$this->always_active_plugins = array(
'batcache/batcache.php',
'posts-to-posts/posts-to-posts.php',
'login-logo/login-logo.php',
'manual-control/manual-control.php',
);
foreach ( $this->always_active_plugins as $p ) {
add_filter( 'plugin_action_links_' . plugin_basename( $p ), array( $this, 'remove_deactivation_link' ) );
}
add_filter( 'option_active_plugins', array( $this, 'active_plugins' ) );
}
function remove_deactivation_link( $actions ) {
unset( $actions['deactivate'] );
return $actions;
}
function active_plugins( $plugins ) {
foreach ( $this->always_active_plugins as $p ) {
if ( !array_search( $p, $plugins ) )
$plugins[] = $p;
}
return $plugins;
}
}
new HBNS_Always_Active_Plugins;
Custom Post Types
I’m using the Products post type in a slightly odd way. You don’t ever go to a product URL. You instead go the URL for the Need that the Product fulfills, and that page lists all of the connected Products. As such, I wanted to make it so that URLs for products pointed to their Need, and I wanted to add an admin bar Edit link for the primary product on its Need page.
<?php
/*
Plugin Name: Post Links
Version: 0.1
Author: Mark Jaquith
Author URI: http://coveredwebservices.com/
*/
// Convenience methods
if(!class_exists('CWS_Plugin_v2')){class CWS_Plugin_v2{function hook($h){$p=10;$m=$this->sanitize_method($h);$b=func_get_args();unset($b[0]);foreach((array)$b as $a){if(is_int($a))$p=$a;else $m=$a;}return add_action($h,array($this,$m),$p,999);}private function sanitize_method($m){return str_replace(array('.','-'),array('_DOT_','_DASH_'),$m);}}}
// The plugin
class CWS_HBNS_Post_Links_Plugin extends CWS_Plugin_v2 {
public static $instance;
public function __construct() {
self::$instance = $this;
$this->hook( 'plugins_loaded' );
}
public function plugins_loaded() {
$this->hook( 'post_type_link' );
$this->hook( 'add_admin_bar_menus' );
}
public function add_admin_bar_menus() {
$this->hook( 'admin_bar_menu', 81 );
}
public function admin_bar_menu( $bar ) {
if ( is_single() && 'need' == get_queried_object()->post_type ) {
$primary_product = new WP_Query( array(
'connected_type' => 'needs_to_products',
'connected_items' => get_queried_object(),
) );
if ( $primary_product->have_posts() ) {
$bar->add_menu( array(
'id' => 'edit-primary-product',
'title' => 'Edit Primary Product',
'href' => get_edit_post_link( $primary_product->posts[0] ),
) );
}
}
}
public function post_type_link( $link, $post ) {
switch ( $post->post_type ) {
case 'product' :
$need = new WP_Query( array(
'connected_type' => 'needs_to_products',
'connected_items' => $post,
) );
if ( $need->have_posts() )
return get_permalink( $need->posts[0] );
break;
}
return $link;
}
}
new CWS_HBNS_Post_Links_Plugin;
For entering data about Products, I made a custom Meta Box that provided a simple interface for entering the Amazon.com link, the approximate price, and then a freeform textarea for key/value pairs and miscellaneous bullet points.

Misc
Because I’m using a Git-backed and Capistrano-deployed repo, I don’t want any local file editing. So I dropped this code in:
<?php
define( 'DISALLOW_FILE_EDIT', true );
function hbns_disable_plugin_deletion( $actions ) {
unset( $actions['delete'] );
return $actions;
}
add_action( 'plugin_action_links', 'hbns_disable_plugin_deletion' );
I was playing a lot with different Product thumbnail sizes, so Viper007Bond’s Regenerate Thumbnails plugin was invaluable, for going back and reprocessing the images I’d uploaded.
And of course, no WordPress developer should make a site without Debug Bar and Debug Bar Console.
Nginx and PHP-FPM
My server runs Nginx and PHP-FPM, in lieu of Apache and mod_php. My normal setup is to use Batcache with an APC backend to do HTML output caching, but I also have an Nginx “microcache” that caches anonymous page views for a short amount of time (5 seconds). But with this site, I wanted to cache more aggressively. Because there are no comments, the site’s content remains static unless we change it. So I cranked my microcache up to 10 minutes (I guess it’s not a microcache anymore!). But I wanted a way to purge the cache if a Product or Post was updated, without having to wait up to 10 minutes. So I modified the Nginx config to recognize a special header that would force a dynamic page load, effectively updating the cache.
Here’s the relevant part of the Nginx config:
location ~ \.php$ {
# Set some proxy cache stuff
fastcgi_cache microcache_fpm;
fastcgi_cache_key $scheme$host$request_method$request_uri;
fastcgi_cache_valid 200 304 10m;
fastcgi_cache_use_stale updating;
fastcgi_max_temp_file_size 1M;
set $no_cache_set 0;
set $no_cache_get 0;
if ( $http_cookie ~* "comment_author_|wordpress_(?!test_cookie)|wp-postpass_" ) {
set $no_cache_set 1;
set $no_cache_get 1;
}
# If a request comes in with a X-Nginx-Cache-Purge: 1 header, do not grab from cache
# But note that we will still store to cache
# We use this to proactively update items in the cache!
if ( $http_x_nginx_cache_purge ) {
set $no_cache_get 1;
}
# For cached requests, tell client to hang on to them for 5 minutes
if ( $no_cache_set = 0 ) {
expires 5m;
}
# fastcgi_no_cache means "Do not store this proxy response in the cache"
fastcgi_no_cache $no_cache_set;
# fastcgi_cache_bypass means "Do not look in the cache for this request"
fastcgi_cache_bypass $no_cache_get;
include /etc/nginx/fastcgi_params;
fastcgi_index index.php;
try_files $uri =404;
fastcgi_pass phpfpm;
}
Now I just needed to have WordPress ping those URLs with that header to refresh them when something changed. Here’s the “meat” of that code:
<?php
public function transition_post_status( $new, $old, $post ) {
if ( 'publish' !== $old && 'publish' !== $new )
return;
$post = get_post( $post );
$url = get_permalink( $post );
// Purge this URL
$this->purge( $url );
// Purge the front page
$this->purge( home_url( '/' ) );
// If a Product changes, flush its Need and that Need's Department
if ( 'product' === $post->post_type ) {
// Flush the connected need
$need = new WP_Query( array(
'connected_type' => 'needs_to_products',
'connected_items' => $post,
) );
if ( $need->have_posts() ) {
$this->purge( get_permalink( $need->posts[0] ) );
// Now this need's connected Department
$department = new WP_Query( array(
'connected_type' => 'departments_to_needs',
'connected_items' => $need->posts[0],
) );
if ( $department->have_posts() )
$this->purge( get_permalink( $department->posts[0] ) );
}
// If a Post changes, flush the main Blog page
} elseif ( 'post' === $post->post_type ) {
$this->purge( home_url( '/blog/' ) );
}
}
private function purge( $url ) {
wp_remote_get( $url, array( 'timeout' => 0.01, 'blocking' => false, 'headers' => array( 'X-Nginx-Cache-Purge' => '1' ) ) );
}
Boom. Now I get the benefit of long cache times, but with the ability to have updates go live quickly when I need to. The upshot here is that while I have Batcache installed, it’s not really going to get a lot of use, as the outer Nginx caching layer should handle everything. This doesn’t just mean that the site scales (Apache Bench has it handling many thousands of requests a second with ease), but that the site is really, really fast to browse. Your experience will vary according to network and geography, of course. But for me, I’m getting 34ms HTML delivery times for pages in the Nginx cache.
Questions?
So that’s how I did it. Let me know if you have any questions!