Contact Us

You’ve built an amazing portfolio plugin. Custom post types? Check. Taxonomies? Check. Beautiful grid with filtering? Check. If you’re new to plugin development, follow our complete WordPress Plugin Development Tutorial where we build a real plugin step-by-step.

But there’s a problem.

When users first activate your plugin and try to visit their portfolio page, they see this:

404 Not Found

The page you requested could not be found.

Your heart sinks. Your plugin is perfect, but the first experience is broken.

What happened? WordPress doesn’t know about your custom post type URLs until you tell it. And the way you tell it is by “flushing rewrite rules.”

In this tutorial, you’ll master WordPress activation hooks. You’ll learn how to:

  • Run code when your plugin activates

  • Set default options automatically

  • Flush rewrite rules at exactly the right time

  • Clean up when your plugin deactivates

No more 404 errors. No more confused users.

Understanding the Problem: Why 404 Errors Happen

When you register a custom post type with 'rewrite' => array('slug' => 'portfolio'), you’re telling WordPress: “Please create URL rules for /portfolio/ and /portfolio/project-name/.”

But WordPress doesn’t automatically add these rules to its database. It waits for something to trigger a “rewrite flush.”

What triggers a rewrite flush?

  • Visiting Settings → Permalinks and clicking “Save Changes”

  • Activating a plugin that calls flush_rewrite_rules()

  • Switching themes (sometimes)

Without a flush, WordPress doesn’t know your URLs exist. Hence, 404.

The Solution: Activation and Deactivation Hooks

WordPress provides hooks that run exactly once when your plugin activates or deactivates:

Hook Function When It Runs
Activation register_activation_hook() When plugin is activated
Deactivation register_deactivation_hook() When plugin is deactivated
Uninstall register_uninstall_hook() When plugin is deleted

Look at your main plugin file (wp-creative-portfolio-pro.php). You already have these hooks set up:

php
/**
 * Plugin Activation
 */
function wp_creative_portfolio_activate() {

    // Set default options if none exist
    if (false === get_option('wp_creative_portfolio_options')) {
        $defaults = array(
            'plural_name'     => __('Portfolios', 'wp-creative-portfolio'),
            'singular_name'   => __('Portfolio', 'wp-creative-portfolio'),
            'slug'            => 'portfolio',
            'taxonomy_slug'   => 'portfolio-category',
            'posts_per_page'  => 9
        );
        update_option('wp_creative_portfolio_options', $defaults);
    }

    // Load plugin class
    $plugin = new WP_Creative_Portfolio();

    // Register CPT & Taxonomy before flushing
    $plugin->register_portfolio_cpt();
    $plugin->register_portfolio_taxonomy();

    // Flush rewrite rules
    flush_rewrite_rules();
}
register_activation_hook(__FILE__, 'wp_creative_portfolio_activate');

/**
 * Plugin Deactivation
 */
function wp_creative_portfolio_deactivate() {
    flush_rewrite_rules();
}
register_deactivation_hook(__FILE__, 'wp_creative_portfolio_deactivate');

WordPress plugins screen showing activation/deactivation links

*Figure 2: Users click these links to activate/deactivate your plugin*

Breaking Down the Activation Hook

Let’s examine each part of your activation function:

1. Setting Default Options

php
if (false === get_option('wp_creative_portfolio_options')) {
    $defaults = array(
        'plural_name'     => __('Portfolios', 'wp-creative-portfolio'),
        'singular_name'   => __('Portfolio', 'wp-creative-portfolio'),
        'slug'            => 'portfolio',
        'taxonomy_slug'   => 'portfolio-category',
        'posts_per_page'  => 9
    );
    update_option('wp_creative_portfolio_options', $defaults);
}

Why this matters: When a user activates your plugin, they expect it to work immediately. If you don’t set defaults, your code might try to read options that don’t exist—causing PHP notices or worse.

Important: We check if options exist first with get_option(). If they do, we don’t overwrite them. This preserves user settings if they’re reactivating.

2. Loading the Plugin Class

php
$plugin = new WP_Creative_Portfolio();

We need an instance of our main class to call its methods. But wait—doesn’t our plugin already create an instance?

php
function run_wp_creative_portfolio_pro() {
    new WP_Creative_Portfolio();
    new WP_Creative_Portfolio_Settings();
}
run_wp_creative_portfolio_pro();

Yes, but that runs on init hook, after WordPress is fully loaded. On activation, WordPress hasn’t loaded everything yet. We need to manually create the instance.

3. Registering Post Types Before Flushing

php
$plugin->register_portfolio_cpt();
$plugin->register_portfolio_taxonomy();

This is critical. If you flush rewrite rules without registering your post types first, WordPress doesn’t know what rules to create. You must register them in the same request.

4. Flushing the Rules

php
flush_rewrite_rules();

This tells WordPress: “Delete all your old URL rules and rebuild them from scratch.” After this call, WordPress knows about /portfolio/ and /portfolio-category/design/.

Why the Deactivation Hook Matters

php
function wp_creative_portfolio_deactivate() {
    flush_rewrite_rules();
}
register_deactivation_hook(__FILE__, 'wp_creative_portfolio_deactivate');

When your plugin deactivates, your custom post types disappear. But WordPress still has rules for them in the database. This can cause 404 errors on other parts of the site.

Flushing on deactivation cleans up those orphaned rules.

The Wrong Way to Flush Rewrite Rules

Never do this:

php
// BAD - Flushes on every page load
add_action('init', 'flush_rewrite_rules');

This would flush rewrite rules on EVERY page load. Your site would slow to a crawl, and you might crash the database.

Also bad:

php
// BAD - Flushes on every admin page load
add_action('admin_init', 'flush_rewrite_rules');

Rewrite rules are stored in the database. Flushing them is an expensive operation—it rebuilds hundreds of rules. Only do it when necessary (activation, deactivation, or slug changes).

Complete Activation Function with Error Handling

Here’s an enhanced version of your activation function with better error handling:

php
/**
 * Plugin Activation
 */
function wp_creative_portfolio_activate() {

    // Set default options if none exist
    if (false === get_option('wp_creative_portfolio_options')) {
        $defaults = array(
            'plural_name'     => __('Portfolios', 'wp-creative-portfolio'),
            'singular_name'   => __('Portfolio', 'wp-creative-portfolio'),
            'slug'            => 'portfolio',
            'taxonomy_slug'   => 'portfolio-category',
            'posts_per_page'  => 9
        );
        update_option('wp_creative_portfolio_options', $defaults);
    }

    // Check if class exists
    if (!class_exists('WP_Creative_Portfolio')) {
        // Something's wrong - include the file manually
        require_once WPCP_PLUGIN_DIR . 'includes/class-wp-creative-portfolio.php';
    }

    // Load plugin class
    $plugin = new WP_Creative_Portfolio();

    // Register CPT & Taxonomy before flushing
    $plugin->register_portfolio_cpt();
    $plugin->register_portfolio_taxonomy();

    // Clear any cached rewrite rules
    delete_option('rewrite_rules');
    
    // Flush rewrite rules
    flush_rewrite_rules();
    
    // Set a transient to show welcome message
    set_transient('wp_creative_portfolio_activated', true, 5);
}
register_activation_hook(__FILE__, 'wp_creative_portfolio_activate');

/**
 * Show welcome message after activation
 */
function wp_creative_portfolio_admin_notice() {
    // Check transient
    if (!get_transient('wp_creative_portfolio_activated')) {
        return;
    }
    
    // Only show to admins
    if (!current_user_can('manage_options')) {
        return;
    }
    ?>
    <div class="notice notice-success is-dismissible">
        <p>
            <strong><?php esc_html_e('WP Creative Portfolio Pro activated!', 'wp-creative-portfolio'); ?></strong><br>
            <?php esc_html_e('Use the shortcode', 'wp-creative-portfolio'); ?> 
            <code>[wp_creative_portfolio]</code> 
            <?php esc_html_e('to display your portfolio.', 'wp-creative-portfolio'); ?><br>
            <a href="<?php echo esc_url(admin_url('options-general.php?page=wp-creative-portfolio')); ?>">
                <?php esc_html_e('Configure Settings', 'wp-creative-portfolio'); ?>
            </a>
        </p>
    </div>
    <?php
    // Delete transient so message only shows once
    delete_transient('wp_creative_portfolio_activated');
}
add_action('admin_notices', 'wp_creative_portfolio_admin_notice');

WordPress admin showing activation success notice

Figure 3: Welcome message displayed after successful activation

Handling Slug Changes in Settings

What if users change the portfolio slug in settings? They’ll get 404 errors again. Here’s how to handle that:

In your settings class (class-wp-creative-portfolio-settings.php), update the sanitize method:

php
public function sanitize($input) {

    // Verify nonce (existing code)
    if (!isset($_POST['_wpnonce']) || !wp_verify_nonce($_POST['_wpnonce'], 'wp_creative_portfolio_options-options')) {
        // ... error handling
    }

    // Get old options to compare
    $old_options = get_option('wp_creative_portfolio_options');
    
    // Sanitize as before
    $sanitized = array();
    $sanitized['plural_name'] = sanitize_text_field($input['plural_name']);
    $sanitized['singular_name'] = sanitize_text_field($input['singular_name']);
    $sanitized['slug'] = sanitize_title($input['slug'], 'portfolio');
    $sanitized['taxonomy_slug'] = sanitize_title($input['taxonomy_slug'], 'portfolio-category');
    $sanitized['posts_per_page'] = intval($input['posts_per_page']);

    // Check if slug changed
    if ($old_options['slug'] !== $sanitized['slug'] || 
        $old_options['taxonomy_slug'] !== $sanitized['taxonomy_slug']) {
        
        // Set a flag to flush rewrite rules
        set_transient('wp_creative_portfolio_flush_rules', true);
    }

    return $sanitized;
}

Then in your main plugin file:

php
/**
 * Check if we need to flush rewrite rules
 */
function wp_creative_portfolio_check_flush() {
    if (get_transient('wp_creative_portfolio_flush_rules')) {
        flush_rewrite_rules();
        delete_transient('wp_creative_portfolio_flush_rules');
    }
}
add_action('init', 'wp_creative_portfolio_check_flush');

This flushes rewrite rules only when slugs change—not on every page load.

Testing Your Activation Hook

Test 1: Fresh Installation

  1. Deactivate and delete your plugin (if installed)

  2. Reinstall and activate it

  3. Check database for wp_creative_portfolio_options (use phpMyAdmin or a plugin like WP Data Access)

  4. Default options should exist

  5. Visit /portfolio/ – should work, not 404

Test 2: Reactivation

  1. Change some settings (different slug, different names)

  2. Deactivate plugin

  3. Reactivate plugin

  4. Check if your settings were preserved (they should be)

  5. URLs should still work

Test 3: Slug Change

  1. Change portfolio slug in settings to “work”

  2. Save settings

  3. Visit /work/ – should work

  4. Old /portfolio/ should 404 (correct behavior)

Test 4: Deactivation Cleanup

  1. Deactivate plugin

  2. Activate another plugin with custom post types (like WooCommerce)

  3. Their URLs should work – your deactivation shouldn’t break other plugins

Common Problems and Solutions

Problem: Still Getting 404 After Activation

Check:

  • Did you register post types BEFORE flushing?

  • Is your .htaccess file writable? (Apache servers)

  • Try manual flush: Settings → Permalinks → Save Changes

Problem: Settings Lost on Reactivation

Check:

  • Your activation hook should only set defaults if options don’t exist

  • You’re using if (false === get_option(...)), not overwriting existing options

Problem: Site Slows Down

Check:

  • Are you flushing rewrite rules on every page load? (You shouldn’t be)

  • Look for flush_rewrite_rules() in init hooks

Problem: “Headers Already Sent” Errors

Check:

  • No output before activation hook (no echoes, no whitespace)

  • Plugin file starts with <?php – no spaces before it

Advanced: Uninstall Hook

When users delete your plugin (not just deactivate), you might want to clean up data:

Create uninstall.php in your plugin root:

php
<?php
/**
 * Uninstall script
 * 
 * Runs when user deletes the plugin
 */

// If uninstall not called from WordPress, exit
if (!defined('WP_UNINSTALL_PLUGIN')) {
    exit;
}

// Delete options
delete_option('wp_creative_portfolio_options');

// If using custom tables, drop them here
// global $wpdb;
// $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}portfolio_stats");

// Clean up any transients
delete_transient('wp_creative_portfolio_flush_rules');

// Flush rewrite rules to clean up
flush_rewrite_rules();

WordPress plugins screen showing Delete link

Figure 4: The Delete link triggers your uninstall.php file

Complete Main Plugin File with Enhanced Activation

Here’s your complete wp-creative-portfolio-pro.php with all activation features:

php
<?php
/**
 * Plugin Name: WP Creative Portfolio Pro
 * Plugin URI:  https://yourwebsite.com/wp-creative-portfolio
 * Description: A complete portfolio plugin with categories, lightbox, and shortcodes.
 * Version:     1.0.0
 * Author:      Your Name
 * Author URI:  https://yourwebsite.com
 * License:     GPL v2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: wp-creative-portfolio
 * Domain Path: /languages
 *
 * @package WP_Creative_Portfolio
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

define( 'WPCP_VERSION', '1.0.0' );
define( 'WPCP_PLUGIN_FILE', __FILE__ );
define( 'WPCP_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'WPCP_PLUGIN_URL', plugin_dir_url( __FILE__ ) );

/**
 * Load plugin text domain
 */
function wp_creative_portfolio_load_textdomain() {
    load_plugin_textdomain(
        'wp-creative-portfolio',
        false,
        dirname( plugin_basename( __FILE__ ) ) . '/languages'
    );
}
add_action( 'plugins_loaded', 'wp_creative_portfolio_load_textdomain' );

// Include main classes
require_once WPCP_PLUGIN_DIR . 'includes/class-wp-creative-portfolio.php';
require_once WPCP_PLUGIN_DIR . 'includes/class-wp-creative-portfolio-settings.php';

/**
 * Plugin Activation
 */
function wp_creative_portfolio_activate() {

    // Set default options if none exist
    if ( false === get_option( 'wp_creative_portfolio_options' ) ) {
        $defaults = array(
            'plural_name'     => __( 'Portfolios', 'wp-creative-portfolio' ),
            'singular_name'   => __( 'Portfolio', 'wp-creative-portfolio' ),
            'slug'            => 'portfolio',
            'taxonomy_slug'   => 'portfolio-category',
            'posts_per_page'  => 9
        );
        update_option( 'wp_creative_portfolio_options', $defaults );
    }

    // Ensure class exists
    if ( ! class_exists( 'WP_Creative_Portfolio' ) ) {
        require_once WPCP_PLUGIN_DIR . 'includes/class-wp-creative-portfolio.php';
    }

    // Load plugin class
    $plugin = new WP_Creative_Portfolio();

    // Register CPT & Taxonomy before flushing
    $plugin->register_portfolio_cpt();
    $plugin->register_portfolio_taxonomy();

    // Clear any cached rules
    delete_option( 'rewrite_rules' );
    
    // Flush rewrite rules
    flush_rewrite_rules();
    
    // Set transient for welcome message
    set_transient( 'wp_creative_portfolio_activated', true, 5 );
}
register_activation_hook( __FILE__, 'wp_creative_portfolio_activate' );

/**
 * Plugin Deactivation
 */
function wp_creative_portfolio_deactivate() {
    flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'wp_creative_portfolio_deactivate' );

/**
 * Check if we need to flush rewrite rules (after slug changes)
 */
function wp_creative_portfolio_check_flush() {
    if ( get_transient( 'wp_creative_portfolio_flush_rules' ) ) {
        flush_rewrite_rules();
        delete_transient( 'wp_creative_portfolio_flush_rules' );
    }
}
add_action( 'init', 'wp_creative_portfolio_check_flush' );

/**
 * Show welcome notice after activation
 */
function wp_creative_portfolio_admin_notice() {
    if ( ! get_transient( 'wp_creative_portfolio_activated' ) ) {
        return;
    }
    
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }
    ?>
    <div class="notice notice-success is-dismissible">
        <p>
            <strong><?php esc_html_e( 'WP Creative Portfolio Pro activated!', 'wp-creative-portfolio' ); ?></strong><br>
            <?php esc_html_e( 'Use the shortcode', 'wp-creative-portfolio' ); ?> 
            <code>[wp_creative_portfolio]</code> 
            <?php esc_html_e( 'to display your portfolio.', 'wp-creative-portfolio' ); ?><br>
            <a href="<?php echo esc_url( admin_url( 'options-general.php?page=wp-creative-portfolio' ) ); ?>">
                <?php esc_html_e( 'Configure Settings', 'wp-creative-portfolio' ); ?>
            </a>
        </p>
    </div>
    <?php
    delete_transient( 'wp_creative_portfolio_activated' );
}
add_action( 'admin_notices', 'wp_creative_portfolio_admin_notice' );

/**
 * Initialize plugin
 */
function run_wp_creative_portfolio_pro() {
    new WP_Creative_Portfolio();
    new WP_Creative_Portfolio_Settings();
}
run_wp_creative_portfolio_pro();

Frequently Asked Questions

What’s the difference between activation and init hooks?

Activation runs once when a plugin is activated, while the init hook runs on every page load. Because of this, functions like flush_rewrite_rules() should never be placed inside init.

Can I use activation hook to create database tables?

Yes. The activation hook is the perfect place to create custom database tables. Always check first whether the table already exists before creating it.

Why flush on deactivation?

Flushing rewrite rules during deactivation removes your plugin’s custom rewrite rules from the database and prevents potential 404 errors on other parts of the website.

What if my plugin has multiple files with activation needs?

Keep all activation logic in a single function inside the main plugin file. Avoid scattering activation hooks across multiple files.

Can I redirect users after activation?

Yes, but it should be done carefully. A better approach is to use a transient and display an admin notice instead of redirecting users automatically.

What about network activation on multisite?

When a plugin is network-activated on a multisite installation, activation hooks run for each site. You should handle this carefully and use checks like is_network_admin() where needed.

Activation Checklist

  • Default options set on activation

  • Existing options preserved (not overwritten)

  • Post types registered before flush

  • Rewrite rules flushed

  • Welcome notice shown (optional)

  • Deactivation hook flushes rules

  • Slug changes trigger flush

  • No flush on every page load

  • Uninstall hook (if needed) for cleanup

Performance Impact

Good: Flush rewrite rules once on activation/deactivation
Bad: Flush on every page load (kills performance)
Better: Flush only when slugs change (via transient)

Rewrite rules are stored in the database. Flushing rebuilds about 50-200 rules depending on your site. It’s not expensive once, but terrible on every page.

What’s Next?

Your plugin now handles activation perfectly:
✅ Sets default options
✅ Registers post types
✅ Flushes rewrite rules correctly
✅ Shows welcome message
✅ Cleans up on deactivation

In Video 10, the final tutorial, you’ll build a complete settings page using the WordPress Settings API. Users will be able to customize labels, slugs, and display options without touching code.

Watch Video/Post 10 – How to Create Plugin Settings Page

Troubleshooting Quick Reference

Problem Likely Cause Solution
404 after activation Post types not registered before flush Register then flush
404 after slug change No flush triggered Use transient to flush
Settings lost Activation overwriting options Check if options exist first
Site slow Flush on every page Remove flush from init
Welcome notice shows repeatedly Transient not deleted Delete after showing

Share Your Success!

You’ve fixed the most annoying problem in custom post type development. Your users will never see those 404 errors.

Question: Did you know about activation hooks before this tutorial? Drop a comment below!

Still getting 404s? Post your code and I’ll help you fix it.

WordPress Core Contributor | Plugin Developer | Educator

Akram Ul Haq is a WordPress core contributor, WordPress.org plugin author, and official translator with 10+ years of development experience. He has created premium plugins on CodeCanyon and professional themes for ThemeForest, along with custom WordPress solutions for businesses worldwide. At WPThrill, he teaches WordPress development, SEO structure, and performance optimization through practical, implementation-focused tutorial series.

Leave a Reply

Your email address will not be published. Required fields are marked *

Subscribe To Our Newsletter & Get Latest Updates.

Copyright @ 2025 WPThrill.com. All Rights Reserved.