Upcoming Events Feature: A Self-Sorting WordPress Events Custom Post Type

COVESA runs a busy in-person calendar. All member meetings, networking receptions, industry conferences, demonstration showcases, dozens of them across a year and across the globe. Their site needed an events page that sorted itself. Upcoming events on top, past events below, and no staff member ever logging in to move an event from one list to the other. The answer was a WordPress events custom post type paired with a date field and two small shortcodes that do the sorting on every page load.

This is the kind of feature that looks trivial from the outside and gets messy fast if you reach for the wrong tool. Here is how I built it, why I skipped the obvious plugin route, and the one tradeoff I accepted to keep it simple.

What COVESA Actually Needed

The brief was short. Show upcoming events in date order. Show past events too, most recent first. When an event's date passes, it should move itself from the upcoming list to the past list. Nobody should have to touch it.

The site is built on Divi, and the events page had to match the rest of the design. That last point rules out a lot of options right away. Most event calendar plugins bring their own templates, their own CSS, and their own opinions about how a page should look. On a Divi site where the layout is already dialed in, that fight is not worth having.

There was also a volume consideration. COVESA has run close to fifty events over the life of the site. The past list keeps growing. Whatever I built had to stay fast and readable as that number climbs.

The Events Post Type That Sorts Itself

Every event on the COVESA site is its own entry in a dedicated events content type. That means each event gets a real URL, a title, a featured image, and its own editable page. An all member meeting lives at a clean address like /event/covesa-all-member-meeting-8/, which is good for sharing and good for search.

The sorting brain is a single date. I attached a date field to each event using Advanced Custom Fields, so whoever adds an event just picks the event date from a calendar picker. No formatting rules to remember. No free text. One field, one date, done.

That date field is the whole trick. Once every event carries a machine-readable date, the site can compare it against today and decide which list the event belongs in. The staff never make that decision. The code does, every time the page loads.

Why Two Shortcodes Instead of an Events Plugin

The events page in the Divi builder is mostly normal Divi sections. The dynamic part is two small Divi Code Modules. One holds a [upcoming-events] shortcode. The other holds a [past-events] shortcode. That is the entire integration. Divi handles the page shell, and the shortcodes drop in the live event lists exactly where they belong.

I registered those two shortcodes in a snippet using the Code Snippets plugin rather than editing a theme file. That keeps the logic out of the theme and safe from theme updates. If you are weighing that choice for your own site, I wrote up the full tradeoff in Code Snippets vs Child Themes. For a self-contained feature like this, a snippet is the right call.

How the Date Split Works

Advanced Custom Fields stores date picker values as a plain Ymd number in the database, so an event dated October 28, 2026 is saved as 20261028. According to Advanced Custom Fields, the return format you choose for display never changes that stored value. It is always the sortable Ymd number underneath.

That single detail makes the whole feature easy. Because the stored date is a sortable number, the query can compare it against today's date, also formatted as Ymd, and split the events cleanly. The upcoming shortcode asks for events dated today or later, in ascending order. The past shortcode asks for events dated before today, in descending order so the most recent past event shows first.

Here is the shape of the query the upcoming shortcode runs. Your field name and ordering may differ, but this is the pattern that does the work.

$today = date( 'Ymd' );

$upcoming = new WP_Query( array(
    'post_type'      => 'event',
    'posts_per_page' => -1,
    'meta_key'       => 'event_date',
    'orderby'        => 'meta_value_num',
    'order'          => 'ASC',
    'meta_query'     => array( array(
        'key'     => 'event_date',
        'value'   => $today,
        'compare' => '>=',
        'type'    => 'NUMERIC',
    ) ),
) );

The past shortcode is the mirror image. It flips the comparison to < and the order to DESC. The ACF documentation confirms that ordering by meta_value_num works precisely because the date is stored as that Ymd number. No date parsing, no timezone math, no cron job flipping a status field at midnight. The comparison happens live, so the lists are always correct the moment someone loads the page.

Building the Cards to Match Divi

Each shortcode loops its events and outputs a row of cards. The markup uses simple wrapper classes so the styling lives in the site's own stylesheet, not buried in the plugin. That is the payoff of skipping a calendar plugin. Every card matches the Divi design because the design is mine, not a vendor's.

A card shows the featured image, the event title linked to its own page, and the date. Nothing exotic. The value is not in the card design. It is in the fact that the right cards appear in the right list without anyone curating them.

The Tradeoff I Accepted: Everything Loads at Once

Here is the honest part. The current build loads the full past list on a single page. With close to fifty events that is fine. The page is quick and the list is readable. But a past-events list only grows, and at some point loading every past event at once stops being a good idea.

I knew that going in and accepted it, because the alternative added complexity the site did not need yet. The events page already carries a disabled "Archived Events" section pointing at a future archive route. That is the escape hatch. When the past list gets long enough to matter, the fix is to paginate it or move older events behind that archive, not to rebuild the sorting logic. The sorting was always the hard part, and it is done.

The other small tradeoff is that whoever adds an event has to fill in the date field. Miss it, and the event has no date to sort by. In practice that is a training note, not a real risk. One required field is a fair price for a page that never needs manual sorting again.

What the Client Actually Got

COVESA got an events page that runs itself. Staff add an event, pick a date, and publish. The event shows up in the upcoming list in the correct position. When its date passes, it drops into the past list on its own. No one logs in to reorder anything.

The page currently manages close to fifty events across the upcoming and past sections, and it renders both lists live on every visit. Because each event is its own entry in the events content type, every event also has a shareable URL and shows up in the site's event sitemap, which helps search engines find them. That is a nice side benefit of doing this with a proper content type instead of a wall of text on one page.

Most of all, the design never broke. The events sit inside the same Divi layout as the rest of the site because I built the cards, not a plugin. This is the same reason I reach for custom code on client projects where the look has to be exact.

What I'd Tell Myself Before Starting This

The lesson here is not about events. It is about picking the smallest tool that solves the actual problem. A WordPress events custom post type with one date field and two shortcodes did everything COVESA asked for, and it did it in less code than configuring a full calendar plugin would have taken.

Most people don't realize how much of a plugin's weight is stuff you will never use. An events calendar plugin ships with ticketing, RSVPs, recurring events, map views, and a dozen settings screens. COVESA needed none of that. They needed two sorted lists. When the requirement is that clean, the custom route is usually lighter, faster, and easier to live with.

The good news is that the pattern is reusable. Any listing that sorts by a date, job openings, webinars, deadlines, works the same way. One content type, one date field, one numeric comparison. Once you have built it once, you will see it everywhere.

Frequently Asked Questions

Do I need an events plugin to build an events page in WordPress?

No. For the COVESA site I used a WordPress events custom post type, an Advanced Custom Fields date field, and two small shortcodes. That combination sorts upcoming and past events automatically without a dedicated calendar plugin, and it keeps the design under your control instead of a vendor's template.

How do events move from upcoming to past automatically?

Each event stores its date as a sortable Ymd number through ACF. On every page load the shortcodes compare that stored date against today's date. Events dated today or later show in the upcoming list. Events dated earlier show in the past list. No cron job or manual status change is involved.

Why store the event date in a custom field instead of using the publish date?

The WordPress publish date reflects when you created the entry, not when the event happens. A dedicated ACF date field lets an event be published today but sorted by a date months in the future, which is exactly what an events listing needs.

Will this approach stay fast as the number of events grows?

For a listing in the dozens it stays fast because the numeric date comparison is cheap and WordPress indexes post meta. As a past-events archive grows into the hundreds, the right move is to paginate the past list rather than load every event at once.

The Complete Code

Here is the complete events snippet in one piece, exactly as it runs. Add it to Code Snippets as a PHP snippet running everywhere, then place the shortcode wherever the list should appear. It expects an event post type with an event date stored in a custom field, which is the part you will need to match to your own setup before the sorting behaves.

Copy the block below with the Copy button in its top right corner.

// Register the shortcode
function upcoming_events_shortcode() {
  // Your HTML content
global $post, $wpdb;

// The SQL query
$sql = "
select * from (
SELECT 
    concat('<strong>',
    CASE 
        WHEN e.TitleURL >'' THEN CONCAT('<a href=\"', e.TitleURL, '\" class=\"sitez-anchor\">', e.Title, '</a>')
        ELSE e.Title
    END,
    '</strong>')
    as Title,
    DATE_FORMAT(e.StartDate, '%Y-%m-%d') AS StartDate, 
    DATE_FORMAT(e.EndDate, '%Y-%m-%d') AS EndDate,
    CASE 
        WHEN e.StartDate IS NOT NULL AND e.EndDate IS NOT NULL AND e.EndDate != '' THEN
            CASE
                WHEN DATE_FORMAT(STR_TO_DATE(e.StartDate, '%Y%m%d'), '%Y%m') = DATE_FORMAT(STR_TO_DATE(e.EndDate, '%Y%m%d'), '%Y%m') THEN 
                    CONCAT(DATE_FORMAT(STR_TO_DATE(e.StartDate, '%Y%m%d'), '%M %e'), 
                           CASE WHEN DATE_FORMAT(STR_TO_DATE(e.StartDate, '%Y%m%d'), '%e') != DATE_FORMAT(STR_TO_DATE(e.EndDate, '%Y%m%d'), '%e') 
                                THEN CONCAT('-', DATE_FORMAT(STR_TO_DATE(e.EndDate, '%Y%m%d'), '%e')) 
                                ELSE '' 
                           END, 
                           ', ', DATE_FORMAT(STR_TO_DATE(e.StartDate, '%Y%m%d'), '%Y'))
                WHEN DATE_FORMAT(STR_TO_DATE(e.StartDate, '%Y%m%d'), '%Y') = DATE_FORMAT(STR_TO_DATE(e.EndDate, '%Y%m%d'), '%Y') THEN 
                    CONCAT(DATE_FORMAT(STR_TO_DATE(e.StartDate, '%Y%m%d'), '%M %e'), 
                           ' - ', 
                           DATE_FORMAT(STR_TO_DATE(e.EndDate, '%Y%m%d'), '%M %e, %Y'))
                ELSE 
                    CONCAT(DATE_FORMAT(STR_TO_DATE(e.StartDate, '%Y%m%d'), '%M %e, %Y'), 
                           ' - ', 
                           DATE_FORMAT(STR_TO_DATE(e.EndDate, '%Y%m%d'), '%M %e, %Y'))
            END
        WHEN e.StartDate IS NOT NULL THEN
            DATE_FORMAT(STR_TO_DATE(e.StartDate, '%Y%m%d'), '%M %e, %Y')
        ELSE
            ''
    END AS DateRange,  
    e.Location,
    CASE 
        WHEN e.URL1 >'' THEN CONCAT('<a href=\"', e.URL1, '\" class=\"sitez-anchor\">', e.URL1Name, '</a>')
        ELSE e.URL1Name
    END as Link1,
    CASE 
        WHEN e.URL2 >'' THEN CONCAT('<a href=\"', e.URL2, '\" class=\"sitez-anchor\">', e.URL2Name, '</a>')
        ELSE e.URL2Name
    END as Link2,
    (@row_number := @row_number + 1) AS RowNumber,
    CASE 
        WHEN (@row_number % 3 = 1) THEN 1
        WHEN (@row_number % 3 = 2) THEN 2
        ELSE 3 
    END AS ColumnNumber
FROM (
    SELECT 
        p.ID,
        p.post_title AS Title,
        pm_title_url.meta_value AS TitleURL,
        pm_start_date.meta_value AS StartDate,
        pm_end_date.meta_value AS EndDate,
        pm_location.meta_value AS Location,
        pm_url1.meta_value AS URL1,
        pm_url1_name.meta_value AS URL1Name,
        pm_url2.meta_value AS URL2,
        pm_url2_name.meta_value AS URL2Name
    FROM wp_posts p
    LEFT JOIN wp_postmeta pm_title_url ON p.ID = pm_title_url.post_id AND pm_title_url.meta_key = 'title_url'
    LEFT JOIN wp_postmeta pm_start_date ON p.ID = pm_start_date.post_id AND pm_start_date.meta_key = 'start_date'
    LEFT JOIN wp_postmeta pm_end_date ON p.ID = pm_end_date.post_id AND pm_end_date.meta_key = 'end_date'
    LEFT JOIN wp_postmeta pm_location ON p.ID = pm_location.post_id AND pm_location.meta_key = 'location'
    LEFT JOIN wp_postmeta pm_url1 ON p.ID = pm_url1.post_id AND pm_url1.meta_key = 'url1'
    LEFT JOIN wp_postmeta pm_url1_name ON p.ID = pm_url1_name.post_id AND pm_url1_name.meta_key = 'url1_name'
    LEFT JOIN wp_postmeta pm_url2 ON p.ID = pm_url2.post_id AND pm_url2.meta_key = 'url2'
    LEFT JOIN wp_postmeta pm_url2_name ON p.ID = pm_url2_name.post_id AND pm_url2_name.meta_key = 'url2_name'
    WHERE p.post_type = 'event'
    AND p.post_status = 'publish'
    ORDER BY pm_start_date.meta_value ASC, pm_end_date.meta_value ASC
) e,
(SELECT @row_number := 0) r
ORDER BY RowNumber
) as e where StartDate >= curdate();
";

  // Execute the query
  $results = $wpdb->get_results($sql, OBJECT);

if (!empty($results)) {
    // Variable to keep track of the current category
    $colCount = 0;
    $rows = null;

    foreach ($results as $event) {

    if ($rows == null || $colCount >= 3) {// Start a new row and reset column count
        if ($rows !== null) {
            $rows .= '</div>'; // Close the previous row
        }
        $rows .=  '<div class="et_pb_row sitez-event-row">';
        $colCount = 0;
    }

    // Display the logo image
    $rows .=  '<div class="et_pb_column et_pb_column_1_3 et_pb_column_17 et_pb_css_mix_blend_mode_passthrough sitez-event-container">';
    $rows .=  $event->Title . '<br>';
    if($event->StartDate>''){$rows .= $event->DateRange . '<br>';}
    if($event->Location>''){$rows .= $event->Location . '<br>';}
    if($event->Link1>''){$rows .= $event->Link1 . '<br>';}
    if($event->Link2>''){$rows .= $event->Link2 . '<br>';}
    $rows .=  '</div>';

    $colCount++;

    // If this is the last item, close the div
    if (end($results) === $event && $colCount <= 3) {
        $rows .=  '</div>'; // Close the last row if needed
    }

    }
    // Check if the last row was closed in the loop
    if ($colCount > 0) {
        $rows .=  '</div>'; // Ensure the row is closed
    }

} else {
    echo "No events found.";
}
$output = <<<ID
<style>

  .sitez-event-row {
    margin: 0;
    width: 100%
  }

  .sitez-event-container {
    font-size: 18px;
    font-weight: 600;
  }
  
  .sitez-anchor {
    text-decoration: underline; /* Optional: Removes underline from links */
    color: #1d1d1d !important;
  }
/*
  /* Responsive adjustments */
  @media (max-width: 980px) {
    .sitez-event-row {
      gap: 5%;
  }
  
    .sitez-event-container {
     // width: 5%;
     // height: 90px;
    }
	.heading {
	  font-size: 26px;
    }
	.view-by-text {
      font-size: 17px;
    }
  }

  @media (max-width: 768px) {
    .sitez-event-row {
      gap: 8%;
	}
    .sitez-event-container {

    }
    .sitez-event-container {

    }
    .heading {
	  font-size: 24px;
    }
    .view-by-text {
      font-size: 16px;
    }
  }

  @media (max-width: 480px) {
    .sitez-event-container {

    }
  }
*/
</style>
{$rows}
ID;
return $output;
}

add_shortcode('upcoming-events', 'upcoming_events_shortcode');

Sources

0 Comments

Submit a Comment

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Post Search

Follow Us

Feel free to follow us on social media for the latest news and more inspiration.

Related Content