Personal notes on making things Programming ✳ Technology ✳ Life
Back to the notebook
WordPress / STORY 5 MIN READ

Prepare a literal LIKE search in WordPress

Use esc_like() and prepare() for different jobs, then prove that percent signs, underscores, and apostrophes behave as intended.

Search the literal text
THE IDEA, THEN THE DETAILS
In this piece

You search for a post title containing 100%, but results containing 1000 also appear. The SQL may be protected from injection and still interpret the search differently from what the reader intended. A percent sign has a special meaning inside a LIKE pattern.

Let’s separate those two concerns with a small title lookup. The example returns at most 20 IDs for published, non-password-protected posts. It is a database exercise, not a public search endpoint. I tested it with WordPress 7.1.2, PHP 8.4.3, and MySQL 8.4.0 in a disposable local installation.

The short version

  • Escape the literal search text for LIKE first.
  • Pass the complete pattern as a prepared string value.
  • Check results against fixtures that distinguish a wildcard from a literal character.

Build the pattern before preparing the query

In LIKE, % matches a sequence of characters and _ matches one character. wpdb::esc_like() escapes those special characters in the supplied text. The outer percent signs below are intentional: they mean that the literal text may appear anywhere in the title.

That pattern still needs SQL preparation. wpdb::prepare() supplies the string values through unquoted %s placeholders. Keep the complete LIKE pattern in its argument; do not interpolate the reader’s text into the query template.

Keep the query’s structure fixed

Save this as find-titles.php outside the public web directory. It expects an already-decoded PHP string. The length rule is measured in bytes, so a multibyte character may use more than one byte. Invalid lengths produce an exception instead of an unrestricted empty search.

find-titles.php
<?php
function notebook_find_titles(string $needle): array {
    global $wpdb;
    if ($needle === '' || strlen($needle) > 80) {
        throw new InvalidArgumentException('Use 1 to 80 bytes.');
    }
    $pattern = '%' . $wpdb->esc_like($needle) . '%';
    $sql = $wpdb->prepare(
        "SELECT ID FROM {$wpdb->posts}
         WHERE post_type = %s AND post_status = %s
           AND post_password = '' AND post_title LIKE %s
         ORDER BY ID ASC LIMIT 20",
        'post', 'publish', $pattern
    );
    $ids = $wpdb->get_col($sql);
    if ($wpdb->last_error !== '') {
        throw new RuntimeException('Title search failed.');
    }
    return array_map('intval', $ids);
}

The table name comes from WordPress’s own $wpdb->posts property. The selected column, ordering, and limit are fixed in the code. No request parameter chooses them. If you later add sorting options, map a small allowlist of accepted values to fixed query choices.

A direct SQL query bypasses WP_Query filters and plugin access rules. On a membership site, “published” may not mean publicly readable. Keep this as an isolated exercise unless you also implement the site’s actual visibility policy. Prefer WordPress’s higher-level query APIs when they fit the task.

Use fixtures that can prove the distinction

Save this test beside the function. Run wp eval-file /absolute/path/check-titles.php only on a disposable WordPress installation. It creates seven posts and permanently deletes those specific fixture IDs in finally. The unique title prefix keeps unrelated posts out of the expected results.

check-titles.php
<?php
// Disposable WordPress only: creates and permanently deletes fixtures.
require __DIR__ . '/find-titles.php';
$prefix = 'kn-' . wp_generate_uuid4() . ' ';
$ids = [];
try {
    $fixtures = [
        ['100% ready', 'publish', ''],
        ['1000 ready', 'publish', ''],
        ['under_score', 'publish', ''],
        ['underXscore', 'publish', ''],
        ["reader's guide", 'publish', ''],
        ['100% draft', 'draft', ''],
        ['100% protected', 'publish', 'secret'],
    ];
    foreach ($fixtures as [$title, $status, $password]) {
        $id = wp_insert_post(wp_slash([
            'post_title' => $prefix . $title,
            'post_type' => 'post', 'post_status' => $status,
            'post_password' => $password,
        ]), true);
        if (is_wp_error($id)) {
            throw new RuntimeException($id->get_error_message());
        }
        $ids[] = $id;
    }
    $cases = [
        ['100%', [$ids[0]]],
        ['under_', [$ids[2]]],
        ["reader's", [$ids[4]]],
        ["' OR 1=1 --", []],
        ['missing', []],
    ];
    foreach ($cases as [$term, $expected]) {
        if (notebook_find_titles($prefix . $term) !== $expected) {
            throw new RuntimeException('Wrong result for: ' . $term);
        }
    }
    foreach (['', str_repeat('x', 81)] as $invalid) {
        try {
            notebook_find_titles($invalid);
        } catch (InvalidArgumentException $error) {
            continue;
        }
        throw new RuntimeException('Invalid length accepted.');
    }
    echo "PASS: 5 searches and 2 rejected lengths\n";
} finally {
    foreach ($ids as $id) {
        wp_delete_post($id, true);
    }
}

Expect PASS: 5 searches and 2 rejected lengths. Searching for the percent title must exclude the 1000 title, the draft, and the password-protected post. Searching for an underscore must exclude the title containing X in its place. The apostrophe remains searchable, while the SQL-looking input is treated as search text and returns no fixture.

The expected IDs come from the fixture insertion results, not from a second copy of the search query. This matters: repeating the same query to calculate the expected answer could repeat the same mistake. The two length checks also confirm that the function refuses empty and oversized input before querying.

Know what these tests do not establish

This is a small correctness check, not a performance benchmark or a complete injection audit. The leading wildcard can make a large title search expensive; limiting returned rows does not prove that the database examined few rows. Database collation also affects case and accent matching, so “literal” here refers to wildcard characters, not byte-for-byte comparison.

Keep the fixtures away from production. If the process is forcibly interrupted before cleanup, remove the disposable database or restore its snapshot; do not run a broad title-based delete on a real site. The function itself only reads. If you render titles afterward, apply the appropriate output escaping.

Try it yourself

In a copy of the function, replace $wpdb->esc_like($needle) with $needle and rerun the same test. The 100% case should fail because it also matches 1000. Restore esc_like() and confirm the suite passes. Keep prepare() in place throughout this exercise.

END OF STORY

KEEP WANDERING

Validate a WordPress setting with an allowlist

Accept exactly the values a feature understands, reject unexpected types, and test the difference between validation and sanitization.

Read the next story
YOUR READING LIST

Saved for later.

Saved in this browser only.