Contact Us

You’ve done the hard work. You’ve built a custom post type for portfolio items. You’ve created categories to organize them. You’ve registered your assets and built a shortcode foundation.

But right now, your shortcode just shows placeholder text. “Portfolio items will appear here.” Not exactly impressive.

Today, that changes. Also, if you are new, check our full tutorial here to get full understanding about plugin development.

In this tutorial, you’ll finally bring your portfolio to life. You’ll use WP_Query to fetch actual portfolio items from the database. You’ll display them in a beautiful grid. You’ll add category filter buttons so users can sort by project type. And you’ll integrate with the lightbox so images pop up beautifully.

By the end, your plugin will be fully functional—ready to use on real client sites.

What We’re Building Today

We’re replacing that placeholder text with a real portfolio grid. Here’s what your render_portfolio() method will do:

  1. Query portfolio items using WP_Query

  2. Get all categories for filter buttons

  3. Loop through items and display each one

  4. Add category classes for JavaScript filtering

  5. Include lightbox links for images

  6. Reset post data to prevent conflicts

Browser showing shortcode output with title and description
Figure 1: The finished portfolio grid with filterable categories

Understanding WP_Query

WP_Query is WordPress’s most powerful class for fetching content. Unlike get_posts(), it gives you access to the Loop—the familiar have_posts() and the_post() functions you know from theme development.

Basic query for our portfolio:

php
$args = array(
    'post_type'      => 'wp_portfolio',
    'posts_per_page' => -1, // Get all items
    'post_status'    => 'publish',
);

$query = new WP_Query($args);

if ($query->have_posts()) :
    while ($query->have_posts()) : $query->the_post();
        // Display each item
    endwhile;
    wp_reset_postdata();
endif;

The Complete Updated Shortcode

Replace your existing render_portfolio() method with this complete code:

php
/**
 * Render the portfolio shortcode
 * 
 * @param array $atts Shortcode attributes
 * @return string HTML output
 */
public function render_portfolio($atts) {

    // =============================================
    // 1. ENQUEUE ASSETS (ONLY WHEN SHORTCODE IS USED)
    // =============================================
    self::enqueue_assets();

    // =============================================
    // 2. GET SETTINGS FROM DATABASE
    // =============================================
    $options = get_option('wp_creative_portfolio_options');
    $posts_per_page = !empty($options['posts_per_page']) ? intval($options['posts_per_page']) : -1;

    // =============================================
    // 3. DEFINE SHORTCODE ATTRIBUTES WITH DEFAULTS
    // =============================================
    $atts = shortcode_atts(
        array(
            'title'       => __('My Portfolio', 'wp-creative-portfolio'),
            'description' => __('Check out my latest work', 'wp-creative-portfolio'),
            'category'    => '',
            'columns'     => 3,
            'count'       => $posts_per_page,
        ),
        $atts,
        'wp_creative_portfolio'
    );

    // =============================================
    // 4. SANITIZE ATTRIBUTES
    // =============================================
    $title       = sanitize_text_field($atts['title']);
    $description = sanitize_textarea_field($atts['description']);
    $category    = sanitize_title($atts['category']);
    $columns     = intval($atts['columns']);
    $count       = intval($atts['count']);

    // Validate columns (ensure between 1-4)
    if ($columns < 1 || $columns > 4) {
        $columns = 3;
    }

    // =============================================
    // 5. GET CATEGORIES FOR FILTER BUTTONS
    // =============================================
    $tax_args = array(
        'taxonomy'   => 'wp_portfolio_category',
        'hide_empty' => true, // Only show categories with items
    );
    
    // If specific category requested, only get that one
    if (!empty($category)) {
        $tax_args['slug'] = $category;
    }
    
    $terms = get_terms($tax_args);

    // =============================================
    // 6. BUILD WP_QUERY ARGUMENTS
    // =============================================
    $args = array(
        'post_type'      => 'wp_portfolio',
        'posts_per_page' => $count,
        'post_status'    => 'publish',
        'orderby'        => 'date',
        'order'          => 'DESC',
    );

    // Add category filter if specified
    if (!empty($category)) {
        $args['tax_query'] = array(
            array(
                'taxonomy' => 'wp_portfolio_category',
                'field'    => 'slug',
                'terms'    => $category,
            ),
        );
    }

    // =============================================
    // 7. RUN THE QUERY
    // =============================================
    $query = new WP_Query($args);

    // =============================================
    // 8. START OUTPUT BUFFERING
    // =============================================
    ob_start();
    ?>

    <div class="wp-creative-portfolio" data-columns="<?php echo esc_attr($columns); ?>">
        
        <?php if (!empty($title) || !empty($description)) : ?>
        <div class="portfolio-header">
            
            <?php if (!empty($title)) : ?>
                <h2 class="portfolio-title"><?php echo esc_html($title); ?></h2>
            <?php endif; ?>
            
            <?php if (!empty($description)) : ?>
                <div class="portfolio-description">
                    <p><?php echo esc_html($description); ?></p>
                </div>
            <?php endif; ?>
            
        </div>
        <?php endif; ?>
        
        <!-- Category Filter Buttons -->
        <?php if (empty($category) && !empty($terms) && !is_wp_error($terms)) : ?>
        <ul class="portfolio-filter">
            <li class="filter-active">
                <a href="#" data-filter="*"><?php esc_html_e('All', 'wp-creative-portfolio'); ?></a>
            </li>
            <?php foreach ($terms as $term) : ?>
                <li>
                    <a href="#" data-filter=".<?php echo esc_attr($term->slug); ?>">
                        <?php echo esc_html($term->name); ?>
                    </a>
                </li>
            <?php endforeach; ?>
        </ul>
        <?php endif; ?>
        
        <!-- Portfolio Grid -->
        <div class="portfolio-grid">
            
            <?php if ($query->have_posts()) : ?>
                
                <?php while ($query->have_posts()) : $query->the_post(); ?>
                    
                    <?php
                    // Get categories for this item (for filter classes)
                    $item_categories = get_the_terms(get_the_ID(), 'wp_portfolio_category');
                    $filter_classes = '';
                    
                    if ($item_categories && !is_wp_error($item_categories)) {
                        foreach ($item_categories as $cat) {
                            $filter_classes .= $cat->slug . ' ';
                        }
                    }
                    
                    // Get featured image URL
                    $thumbnail_url = get_the_post_thumbnail_url(get_the_ID(), 'large');
                    $full_image_url = get_the_post_thumbnail_url(get_the_ID(), 'full');
                    
                    // If no featured image, use placeholder
                    if (empty($thumbnail_url)) {
                        $thumbnail_url = 'https://via.placeholder.com/600x400?text=' . urlencode(get_the_title());
                        $full_image_url = $thumbnail_url;
                    }
                    ?>
                    
                    <div class="portfolio-item <?php echo esc_attr($filter_classes); ?>">
                        <div class="portfolio-item-inner">
                            
                            <figure class="portfolio-image">
                                <img src="<?php echo esc_url($thumbnail_url); ?>" 
                                     alt="<?php the_title_attribute(); ?>">
                                
                                <div class="portfolio-overlay">
                                    <a href="<?php echo esc_url($full_image_url); ?>" 
                                       class="portfolio-lightbox" 
                                       data-rel="prettyPhoto[portfolio]"
                                       title="<?php the_title_attribute(); ?>">
                                        <i class="ti-plus"></i>
                                    </a>
                                </div>
                            </figure>
                            
                            <div class="portfolio-content">
                                <span class="portfolio-date"><?php echo get_the_date(); ?></span>
                                <h3 class="portfolio-title">
                                    <a href="<?php the_permalink(); ?>">
                                        <?php the_title(); ?>
                                    </a>
                                </h3>
                            </div>
                            
                        </div>
                    </div>
                    
                <?php endwhile; ?>
                
                <?php wp_reset_postdata(); ?>
                
            <?php else : ?>
                
                <p class="portfolio-no-items">
                    <?php esc_html_e('No portfolio items found.', 'wp-creative-portfolio'); ?>
                </p>
                
            <?php endif; ?>
            
        </div><!-- .portfolio-grid -->
        
    </div><!-- .wp-creative-portfolio -->

    <?php
    // =============================================
    // 9. RETURN THE BUFFERED CONTENT
    // =============================================
    return ob_get_clean();
}

Complete shortcode code for render method

Figure 2: The complete shortcode method with WP_Query and grid display

Breaking Down the Query

Basic Query Arguments

php
$args = array(
    'post_type'      => 'wp_portfolio',      // Our custom post type
    'posts_per_page' => $count,               // From shortcode attribute
    'post_status'    => 'publish',            // Only published items
    'orderby'        => 'date',                // Newest first
    'order'          => 'DESC',                // Descending order
);

Available orderby options:

  • date – By publish date

  • title – Alphabetical by title

  • rand – Random order (fun for portfolios!)

  • menu_order – Custom order (if you add page attributes)

Category Filtering with Tax Query

php
if (!empty($category)) {
    $args['tax_query'] = array(
        array(
            'taxonomy' => 'wp_portfolio_category',
            'field'    => 'slug',              // We're using slug
            'terms'    => $category,           // Category from shortcode
        ),
    );
}

This adds a filter to show only items from a specific category. When a user uses [wp_creative_portfolio category="web-design"], they’ll only see web design projects.

Getting Categories for Filter Buttons

Before displaying items, we need to get all categories for the filter buttons:

php
$tax_args = array(
    'taxonomy'   => 'wp_portfolio_category',
    'hide_empty' => true, // Only show categories with items
);

$terms = get_terms($tax_args);

Why hide_empty? It prevents showing categories that have no portfolio items. Cleaner UI.

Then we display them as filter buttons:

php
<ul class="portfolio-filter">
    <li class="filter-active">
        <a href="#" data-filter="*">All</a>
    </li>
    <?php foreach ($terms as $term) : ?>
        <li>
            <a href="#" data-filter=".<?php echo esc_attr($term->slug); ?>">
                <?php echo esc_html($term->name); ?>
            </a>
        </li>
    <?php endforeach; ?>
</ul>

The Portfolio Item Loop

Inside the loop, we display each portfolio item:

php
while ($query->have_posts()) : $query->the_post();
    
    // Get categories for this item
    $item_categories = get_the_terms(get_the_ID(), 'wp_portfolio_category');
    $filter_classes = '';
    
    if ($item_categories && !is_wp_error($item_categories)) {
        foreach ($item_categories as $cat) {
            $filter_classes .= $cat->slug . ' ';
        }
    }
    
    // Get featured image
    $thumbnail_url = get_the_post_thumbnail_url(get_the_ID(), 'large');
    $full_image_url = get_the_post_thumbnail_url(get_the_ID(), 'full');
    ?>
    
    <div class="portfolio-item <?php echo esc_attr($filter_classes); ?>">
        <!-- Item content -->
    </div>
    
<?php endwhile; ?>

Understanding the Filter Classes

The $filter_classes variable adds category slugs as CSS classes:

html
<div class="portfolio-item web-design branding">

The JavaScript filtering library (in your plugins.js) looks for these classes. When someone clicks “Web Design”, it hides all items without the web-design class.

Featured Image Handling

We handle images carefully:

php
// Get featured image URLs
$thumbnail_url = get_the_post_thumbnail_url(get_the_ID(), 'large');
$full_image_url = get_the_post_thumbnail_url(get_the_ID(), 'full');

// If no featured image, use placeholder
if (empty($thumbnail_url)) {
    $thumbnail_url = 'https://via.placeholder.com/600x400?text=' . urlencode(get_the_title());
    $full_image_url = $thumbnail_url;
}

Why both sizes?

  • large – For the grid display (faster loading)

  • full – For the lightbox popup (full resolution)

Placeholder fallback: If a user forgets to set a featured image, we show a placeholder with the project title. Much better than a broken image icon.

The Lightbox Integration

Look at the overlay link:

php
<a href="<?php echo esc_url($full_image_url); ?>" 
   class="portfolio-lightbox" 
   data-rel="prettyPhoto[portfolio]"
   title="<?php the_title_attribute(); ?>">
    <i class="ti-plus"></i>
</a>

This works with the PrettyPhoto library (in your plugins.js) to create a beautiful lightbox popup when someone clicks the plus icon.

Lightbox popup showing full-size image

*Figure 4: Lightbox popup displaying the full-size portfolio image*

Resetting Post Data

After any custom query, ALWAYS reset post data:

php
<?php wp_reset_postdata(); ?>

Why this matters: Without resetting, subsequent WordPress functions (like the_title() outside your loop) would use the last post from your query instead of the main query. This breaks themes and other plugins.

Complete Class Integration

Here’s how your render_portfolio() method fits into the complete class:

php
class WP_Creative_Portfolio {
    
    public function __construct() {
        add_action('init', array($this, 'register_portfolio_cpt'));
        add_action('init', array($this, 'register_portfolio_taxonomy'));
        add_action('wp_enqueue_scripts', array($this, 'register_assets'));
        add_shortcode('wp_creative_portfolio', array($this, 'render_portfolio'));
    }
    
    public function register_portfolio_cpt() { /* from Video 3 */ }
    
    public function register_portfolio_taxonomy() { /* from Video 4 */ }
    
    public function register_assets() { /* from Video 5 */ }
    
    public static function enqueue_assets() { /* from Video 5 */ }
    
    public function render_portfolio($atts) { /* the complete method above */ }
}

Testing Your Portfolio Grid

Step 1: Create Test Data

Create at least 6 portfolio items across different categories:

  • 2 items in “Web Design”

  • 2 items in “Branding”

  • 2 items in “Print”

Make sure each has a featured image.

Step 2: Add the Shortcode

Create a page with [wp_creative_portfolio]

Step 3: Test Filtering

  1. Click “All” – should show all 6 items

  2. Click “Web Design” – should show only 2 items

  3. Click “Branding” – should show only 2 items

  4. Click “Print” – should show only 2 items

Step 4: Test Lightbox

  1. Hover over any portfolio item

  2. Click the plus icon that appears

  3. Lightbox should open with full-size image

  4. Navigation arrows should let you browse all items

Step 5: Test with Attributes

Try different shortcode variations:

text
[wp_creative_portfolio title="Web Design Work" category="web-design" columns="2" count="4"]
[wp_creative_portfolio columns="4" count="8"]
[wp_creative_portfolio category="branding"]

Common Problems and Solutions

Problem: No Items Showing

Check:

  • Do you have published portfolio items?

  • Is posts_per_page set correctly? (-1 shows all)

  • Check browser console for JavaScript errors

  • Verify WP_Query arguments with var_dump($query)

Problem: Filter Buttons Don’t Work

Check:

  • Are category classes being added? (Inspect element)

  • Is plugins.js loading? (Network tab)

  • Check for JavaScript errors in console

  • Verify isotope/ filtering library is present

Problem: Images Not Showing

Check:

  • Did you set featured images?

  • Check image URLs in browser (right-click → Open in new tab)

  • Verify file permissions on uploads folder

  • Check if placeholder appears (means no featured image)

Problem: Lightbox Not Working

Check:

  • Is prettyPhoto included in plugins.js?

  • Check data-rel="prettyPhoto[portfolio]" attribute

  • Verify full-size image URL exists

  • Check for JavaScript conflicts

Problem: Layout Broken/Masonry Not Working

Check:

  • Are all CSS files loading?

  • Check for column classes in grid items

  • Verify isotope initialization in main.js

  • Check for CSS conflicts with theme

Performance Considerations

Your current implementation is efficient because:

  1. Assets load conditionally – Only on pages with shortcode

  2. One database query – WP_Query is optimized

  3. No image resizing – Using WordPress image sizes

  4. Caching ready – WordPress transients could be added for high-traffic sites

Potential optimizations for later:

php
// Add caching for categories
$terms = get_transient('wpcp_portfolio_categories');
if (false === $terms) {
    $terms = get_terms($tax_args);
    set_transient('wpcp_portfolio_categories', $terms, HOUR_IN_SECONDS);
}

Advanced: Customizing the Grid

Your users can already customize with shortcode attributes. Here are examples:

Two-Column Grid

text
[wp_creative_portfolio columns="2"]

Four-Column Grid with Specific Count

text
[wp_creative_portfolio columns="4" count="8"]

Single Category Portfolio

text
[wp_creative_portfolio title="Branding Projects" category="branding"]

Minimal Portfolio (No Title/Description)

text
[wp_creative_portfolio title="" description=""]

Complete Code Reference

Here’s your complete render_portfolio() method for easy copy-pasting:

php
public function render_portfolio($atts) {

    // Enqueue assets
    self::enqueue_assets();

    // Get settings
    $options = get_option('wp_creative_portfolio_options');
    $posts_per_page = !empty($options['posts_per_page']) ? intval($options['posts_per_page']) : -1;

    // Shortcode attributes
    $atts = shortcode_atts(
        array(
            'title'       => __('My Portfolio', 'wp-creative-portfolio'),
            'description' => __('Check out my latest work', 'wp-creative-portfolio'),
            'category'    => '',
            'columns'     => 3,
            'count'       => $posts_per_page,
        ),
        $atts,
        'wp_creative_portfolio'
    );

    // Sanitize
    $title       = sanitize_text_field($atts['title']);
    $description = sanitize_textarea_field($atts['description']);
    $category    = sanitize_title($atts['category']);
    $columns     = intval($atts['columns']);
    $count       = intval($atts['count']);

    // Validate columns
    if ($columns < 1 || $columns > 4) {
        $columns = 3;
    }

    // Get categories for filter
    $tax_args = array(
        'taxonomy'   => 'wp_portfolio_category',
        'hide_empty' => true,
    );
    
    if (!empty($category)) {
        $tax_args['slug'] = $category;
    }
    
    $terms = get_terms($tax_args);

    // Query arguments
    $args = array(
        'post_type'      => 'wp_portfolio',
        'posts_per_page' => $count,
        'post_status'    => 'publish',
        'orderby'        => 'date',
        'order'          => 'DESC',
    );

    if (!empty($category)) {
        $args['tax_query'] = array(
            array(
                'taxonomy' => 'wp_portfolio_category',
                'field'    => 'slug',
                'terms'    => $category,
            ),
        );
    }

    $query = new WP_Query($args);

    ob_start();
    ?>

    <div class="wp-creative-portfolio" data-columns="<?php echo esc_attr($columns); ?>">
        
        <?php if (!empty($title) || !empty($description)) : ?>
        <div class="portfolio-header">
            <?php if (!empty($title)) : ?>
                <h2 class="portfolio-title"><?php echo esc_html($title); ?></h2>
            <?php endif; ?>
            <?php if (!empty($description)) : ?>
                <div class="portfolio-description">
                    <p><?php echo esc_html($description); ?></p>
                </div>
            <?php endif; ?>
        </div>
        <?php endif; ?>
        
        <?php if (empty($category) && !empty($terms) && !is_wp_error($terms)) : ?>
        <ul class="portfolio-filter">
            <li class="filter-active">
                <a href="#" data-filter="*"><?php esc_html_e('All', 'wp-creative-portfolio'); ?></a>
            </li>
            <?php foreach ($terms as $term) : ?>
                <li>
                    <a href="#" data-filter=".<?php echo esc_attr($term->slug); ?>">
                        <?php echo esc_html($term->name); ?>
                    </a>
                </li>
            <?php endforeach; ?>
        </ul>
        <?php endif; ?>
        
        <div class="portfolio-grid">
            
            <?php if ($query->have_posts()) : ?>
                
                <?php while ($query->have_posts()) : $query->the_post(); ?>
                    
                    <?php
                    $item_categories = get_the_terms(get_the_ID(), 'wp_portfolio_category');
                    $filter_classes = '';
                    if ($item_categories && !is_wp_error($item_categories)) {
                        foreach ($item_categories as $cat) {
                            $filter_classes .= $cat->slug . ' ';
                        }
                    }
                    
                    $thumbnail_url = get_the_post_thumbnail_url(get_the_ID(), 'large');
                    $full_image_url = get_the_post_thumbnail_url(get_the_ID(), 'full');
                    
                    if (empty($thumbnail_url)) {
                        $thumbnail_url = 'https://via.placeholder.com/600x400?text=' . urlencode(get_the_title());
                        $full_image_url = $thumbnail_url;
                    }
                    ?>
                    
                    <div class="portfolio-item <?php echo esc_attr($filter_classes); ?>">
                        <div class="portfolio-item-inner">
                            <figure class="portfolio-image">
                                <img src="<?php echo esc_url($thumbnail_url); ?>" 
                                     alt="<?php the_title_attribute(); ?>">
                                <div class="portfolio-overlay">
                                    <a href="<?php echo esc_url($full_image_url); ?>" 
                                       class="portfolio-lightbox" 
                                       data-rel="prettyPhoto[portfolio]"
                                       title="<?php the_title_attribute(); ?>">
                                        <i class="ti-plus"></i>
                                    </a>
                                </div>
                            </figure>
                            <div class="portfolio-content">
                                <span class="portfolio-date"><?php echo get_the_date(); ?></span>
                                <h3 class="portfolio-title">
                                    <a href="<?php the_permalink(); ?>">
                                        <?php the_title(); ?>
                                    </a>
                                </h3>
                            </div>
                        </div>
                    </div>
                    
                <?php endwhile; ?>
                
                <?php wp_reset_postdata(); ?>
                
            <?php else : ?>
                
                <p class="portfolio-no-items">
                    <?php esc_html_e('No portfolio items found.', 'wp-creative-portfolio'); ?>
                </p>
                
            <?php endif; ?>
            
        </div><!-- .portfolio-grid -->
        
    </div><!-- .wp-creative-portfolio -->

    <?php
    return ob_get_clean();
}

Frequently Asked Questions

What’s the difference between wp_enqueue_script and wp_register_script?

wp_register_script() adds the script to WordPress’s registry but doesn’t output it. wp_enqueue_script() actually outputs the script tag. You must register before enqueuing.

Can I enqueue without registering?

Yes! wp_enqueue_script() registers automatically if the handle isn’t found. But it’s better to register separately for organization.

How do I know if my assets are loading?

Use browser DevTools (F12) → Network tab. Reload the page and look for your CSS/JS files.

What if my JavaScript depends on jQuery?

Add ‘jquery’ to the dependencies array. WordPress loads jQuery automatically.

Should I load all CSS files or combine them?

During development, keep them separate for easier debugging. For production, consider combining and minifying.

How do I add inline CSS or JS?

Use wp_add_inline_style() or wp_add_inline_script() after enqueuing.

What’s a handle?

A unique identifier for your asset. Use it to reference the asset elsewhere.

Performance Checklist

  • Assets load only on pages with shortcode

  • One database query for items

  • One database query for categories

  • Image sizes optimized (large for grid, full for lightbox)

  • No PHP notices or warnings

  • Reset post data after custom query

  • Placeholder images for missing featured images

What’s Next?

Your plugin is now fully functional! Users can:
✅ Create portfolio items (Video 3)
✅ Organize with categories (Video 4)
✅ Display with shortcode (Video 6-7)

In Video 8, you’ll learn how to make your plugin production-ready with security best practices. You’ll understand why we’ve been using esc_html()esc_attr(), and sanitize_text_field() throughout—and what else you need to protect your users.

Watch Video/Post 8 – WordPress Plugin Security & Best Practices (coming soon)

Share Your Success!

You’ve just built a complete, functional WordPress plugin from scratch. That’s huge!

Share your portfolio URL in the comments! I’d love to see what you’ve built. Having issues? Post your code and I’ll help troubleshoot.


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.