100 days of WordPress development and plugins: the recap
We've spent the last hundred days exploring WordPress development—from foundational concepts through advanced plugin architecture and modern block-based workflows. What started as a journey through hooks, security patterns, and performance tuning has revealed something important: the practices that separate maintainable, scalable WordPress projects from fragile ones are often the same across different sizes of team and project.
This recap pulls together the lessons that matter most. We're not rehashing theory; we're distilling what actually moves the needle when you're shipping code to production.
Hooks are everything — Extend, never edit core
One of the first things we learned is that WordPress's hook system—filters and actions—is the foundation of extensibility. Every major platform has an extension mechanism; WordPress's is mature, well-documented, and deeply embedded in core itself. But understanding this is different from internalizing it.
The principle is simple: if you're tempted to modify a core file or a theme file directly, stop. Find the hook. If it doesn't exist, create one. This isn't pedantry. Direct edits break the moment you update WordPress, your theme, or any plugin that depends on the code you changed. We've all inherited projects where someone hardcoded a redirect into functions.php or hacked a template—and the first update made it invisible but active, causing silent bugs.
Let's say you need to modify the output of a product loop in WooCommerce. The naive approach:
// Don't do this
// Edit woocommerce/templates/loop/product.php directly
<?php
// Hardcoded changes lost on update
echo '<span class="custom-badge">Exclusive</span>';
?>The right approach uses hooks:
// In your theme or plugin functions.php
add_action( 'woocommerce_before_shop_loop_item', function() {
if ( is_product() ) {
echo '<span class="custom-badge">Exclusive</span>';
}
}, 9 );
// Or use a filter to modify content
add_filter( 'woocommerce_get_price_html', function( $price ) {
return '<em>' . $price . '</em>';
});When you need a hook that doesn't exist, add it to core or your plugin with do_action() or apply_filters(). We've seen teams ship thousands of lines of plugin code with zero direct edits to themes or core—and those codebases stay maintainable through WordPress major version jumps.
Security is your job — Escape, sanitize, nonce and check capabilities
WordPress provides built-in functions for security: wp_nonce_field() and wp_verify_nonce() for CSRF protection, sanitize_text_field() and friends for input cleaning, esc_html() and esc_attr() for output escaping, and capability checks via current_user_can(). These are not optional; they're the minimum bar for any production code.
We've reviewed plugins that skip all four. A form with no nonce, sanitized input stored raw, unescaped output rendered in templates, and no permission checks. That's a vulnerability waiting for a security researcher or bad actor.
Here's what a secure form handler looks like:
// In a plugin or theme
add_action( 'admin_post_save_custom_data', function() {
// 1. Check capability
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Unauthorized' );
}
// 2. Verify nonce
if ( ! isset( $_POST['_wpnonce'] ) ||
! wp_verify_nonce( $_POST['_wpnonce'], 'save_custom_data_nonce' ) ) {
wp_die( 'Nonce verification failed' );
}
// 3. Sanitize input
$title = sanitize_text_field( $_POST['title'] ?? '' );
$content = wp_kses_post( $_POST['content'] ?? '' );
// Store safely
update_option( 'my_custom_title', $title );
update_option( 'my_custom_content', $content );
wp_redirect( admin_url( 'admin.php?page=my-page&saved=1' ) );
exit;
} );
// In the form template
wp_nonce_field( 'save_custom_data_nonce' );
// On output
echo esc_html( get_option( 'my_custom_title' ) );
echo wp_kses_post( get_option( 'my_custom_content' ) );Each step is non-negotiable. Capability checks prevent unauthorized users from doing anything. Nonces prevent CSRF attacks from external sites. Sanitization prevents stored XSS. Escaping prevents reflected and stored XSS on output. Together, they form a defensible perimeter.
One more critical pattern: use prepared statements with wpdb when querying:
global $wpdb;
$user_id = intval( $_GET['user_id'] );
$results = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_author = %d", $user_id )
);Blocks are the future — Block themes, patterns and the Interactivity API
If you're still building PHP-only templates, it's time to rethink. WordPress 6.x has matured the block editor enough that new projects should seriously consider block themes and custom blocks. This isn't to say abandon classical themes—but the trajectory is clear.
Blocks offer several wins: editors can compose content without touching code, custom blocks are version-controlled like any plugin, and the Interactivity API (in 6.5+) brings client-side reactivity without a separate JavaScript framework.
A simple custom block looks like this:
// In plugin.php
register_block_type( __DIR__ . '/blocks/feature-card', array(
'title' => 'Feature Card',
'category' => 'common',
'icon' => 'star-filled',
'render_callback' => function( $attrs, $content ) {
return sprintf(
'<div class="feature-card"><h3>%s</h3><p>%s</p></div>',
esc_html( $attrs['title'] ?? '' ),
wp_kses_post( $content )
);
}
) );For interactive components, the Interactivity API lets you attach behavior directly in PHP-rendered markup, eliminating the need for separate React or Vue initialization:
// render_callback in a block
wp_interactivity_state( 'my-plugin', array( 'isOpen' => false ) );
return sprintf(
'<div data-wp-interactive="my-plugin" data-wp-watch="context.isOpen">
<button data-wp-on--click="actions.toggle">Toggle</button>
<div data-wp-show="context.isOpen">Hidden content</div>
</div>'
);Block themes (using theme.json for styling and block-level configuration) keep concerns separated and make theme maintenance far easier than functions.php + custom CSS + hook soup.
Fast and maintainable — Caching, Git deployments and tests
Performance isn't an afterthought; it's part of architecture. WordPress is I/O-bound. Every database query, every remote API call, every transient miss costs time. The three levers are caching, async processing, and selective queries.
Object caching (via Redis or Memcached, not the default file-based cache) is non-negotiable for high-traffic sites. Configure it early:
// In wp-config.php or a mu-plugin
define( 'WP_CACHE', true );
// In your code
$result = wp_cache_get( 'expensive_data', 'my-plugin' );
if ( false === $result ) {
$result = expensive_computation();
wp_cache_set( 'expensive_data', $result, 'my-plugin', 3600 );
}
echo $result;Transients are also useful for time-bound data like API responses. But remember: transients use object cache if available, otherwise the database. On high-traffic sites without object cache, transients are slower than the computation they cache.
For deployment, Git is your friend. We use managed WordPress hosts with Git push-to-deploy (like WP Engine, Kinsta, Pantheon) or CI/CD pipelines that run tests, build assets, and deploy atomically. This eliminates manual SFTP uploads, SSH copy-paste errors, and the nightmare of "who changed what when."
Tests catch regressions. A small test suite—even just a few critical paths—prevents silent failures after updates. Use PHPUnit or a simpler framework:
// Simple test
class CustomPluginTest extends WP_UnitTestCase {
public function test_sanitize_input() {
$dirty = '<script>alert(1)</script>Test';
$clean = sanitize_text_field( $dirty );
$this->assertEquals( 'alert(1)Test', $clean );
}
public function test_post_saved_with_hook() {
$post_id = wp_insert_post( array( 'post_title' => 'Test' ) );
$this->assertGreaterThan( 0, $post_id );
}
}Tests run in CI before anything touches production. Combined with caching and Git deployments, you get fast, safe, reproducible releases.
Common mistakes
- Trusting user input implicitly. Always sanitize on input, always escape on output. No exceptions.
- Ignoring hooks and editing files directly. This breaks on updates and makes debugging a nightmare when multiple plugins interact.
- Building custom post types without custom capabilities. Users can then edit each other's posts. Extend the capability model to separate concerns.
- Querying all posts to find one. Use proper WP_Query arguments:
posts_per_page,orderby,s. Let the database filter, not PHP. - Shipping without caching strategy. Even a small site with object cache queries the database 10x fewer times.
- Hardcoding paths or options. Use constants like
WP_CONTENT_DIR,ABSPATH, and functions likeget_home_url()so the code moves across environments.
Key takeaways
- Hooks are the contract layer in WordPress. Respect them and your code survives updates.
- Security (escaping, sanitizing, nonces, capabilities) is never optional—it's the baseline for any production code.
- Block-based development is the future. Start learning the block API and theme.json now, even if your current projects use classical themes.
- Performance is architecture, not tuning. Object cache + transients + selective queries + Git deployments compound to fast, maintainable releases.
- Tests and automated deployments prevent silent regressions. Invest in them early.
- WordPress scales to enterprise use when you treat it like a framework, not a CMS. Separate concerns, version everything, and audit security regularly.






Comments
Loading comments…