<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Webequipe</title>
    <description>The latest articles on DEV Community by Webequipe (webequipe).</description>
    <link>https://dev.to/webequipe</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F13953%2Fc215ba67-d5a3-4294-b330-c02b2316a9ea.png</url>
      <title>DEV Community: Webequipe</title>
      <link>https://dev.to/webequipe</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/webequipe"/>
    <language>en</language>
    <item>
      <title>One Plugin, Every Client Stack: Building a Universal WordPress Webhook Adapter</title>
      <dc:creator>Webequipe</dc:creator>
      <pubDate>Tue, 18 Aug 2026 09:08:39 +0000</pubDate>
      <link>https://dev.to/webequipe/one-plugin-every-client-stack-building-a-universal-wordpress-webhook-adapter-65k</link>
      <guid>https://dev.to/webequipe/one-plugin-every-client-stack-building-a-universal-wordpress-webhook-adapter-65k</guid>
      <description>&lt;p&gt;A client asked me for something simple a while back: send a Telegram message every time someone fills out their contact form. Another client had a HubSpot connection that wasn't behaving, so we ended up routing everything through Zapier first, then into HubSpot from there — easier to debug that way anyway. A third wanted Slack pinged on WooCommerce events, different channels depending on whether it was a new order, a refund, or a cancellation.&lt;/p&gt;

&lt;p&gt;Three clients, three completely different destinations, but the same underlying job every time: something happens on WordPress, and it needs to go somewhere else automatically. I was rebuilding this by hand often enough that it stopped making sense not to build it properly once.&lt;/p&gt;

&lt;p&gt;Most plugins that do this kind of thing hard-code support for specific plugins — CF7 today, WPForms next month, LearnDash whenever someone asks enough times. You're always one release behind whatever your client happens to be running. I wanted something that didn't have that ceiling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dedicated adapters where the payload actually needs cleanup.&lt;/strong&gt; Contact Form 7, WPForms, Fluent Forms, Ninja Forms, Formidable Forms, and WooCommerce each get their own adapter. These plugins pass messy native objects full of nonces, reCAPTCHA tokens, and internal metadata mixed in with the real submission data — without cleanup, the payload is basically unusable. Each adapter strips that noise and hands back clean, labeled fields.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A generic adapter for literally everything else.&lt;/strong&gt; This is the part that actually solves the ceiling problem. Instead of writing a new adapter every time a client uses some plugin I've never heard of, there's one listener:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$hook_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$args&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;func_get_args&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;           &lt;span class="c1"&gt;// catch all arguments&lt;/span&gt;
    &lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// normalize to array&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// send to webhook&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;PHP_INT_MAX&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// accept any number of arguments&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PHP_INT_MAX&lt;/code&gt; as the accepted-argument count means it captures every argument a hook fires, no matter which plugin fires it. Point it at &lt;code&gt;learndash_course_completed&lt;/code&gt;, a membership plugin's renewal hook, or something from a plugin that doesn't exist yet — it just works.&lt;/p&gt;

&lt;p&gt;Raw hook arguments aren't that useful on their own, though. &lt;code&gt;user_id = 42&lt;/code&gt; doesn't tell you much by itself. So there's a serialization layer that flattens known WordPress types (&lt;code&gt;WP_Post&lt;/code&gt;, &lt;code&gt;WP_User&lt;/code&gt;, &lt;code&gt;WP_Term&lt;/code&gt;, anything exposing &lt;code&gt;get_data()&lt;/code&gt;) into safe common fields, and — the part I like most — attaches a companion expanded object for documented ID arguments. &lt;code&gt;user_id&lt;/code&gt; gets a full &lt;code&gt;user&lt;/code&gt;object with email, login, and roles attached automatically. Undocumented integer arguments even get a best-effort resolve against posts/users/terms/orders, so an unlabeled hook argument usually still returns something usable instead of a bare number.&lt;/p&gt;

&lt;p&gt;There's also a small data-only dictionary of known WordPress core hooks with pre-documented field names, purely for UI discoverability — no logic in it, just labels so the dropdown makes sense.&lt;/p&gt;

&lt;p&gt;End result: the handful of plugins that genuinely need clean-payload treatment get it, and everything else — any LMS, any membership tool, any custom hook, anything built after this ships — is covered without writing new code for it.&lt;/p&gt;

&lt;p&gt;It's free and live on &lt;a href="https://wordpress.org/plugins/webequipe-webhook-manager/" rel="noopener noreferrer"&gt;WordPress.org&lt;/a&gt; if you want to see it in action.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>opensource</category>
      <category>webhooks</category>
    </item>
    <item>
      <title>The WordPress Excerpt Filter Isn't Universal — A Debugging Story</title>
      <dc:creator>Webequipe</dc:creator>
      <pubDate>Mon, 17 Aug 2026 11:57:39 +0000</pubDate>
      <link>https://dev.to/webequipe/the-wordpress-excerpt-filter-isnt-universal-a-debugging-story-3id0</link>
      <guid>https://dev.to/webequipe/the-wordpress-excerpt-filter-isnt-universal-a-debugging-story-3id0</guid>
      <description>&lt;p&gt;A support ticket landed recently that looked, on the surface, like a rendering bug: a plugin's search results were showing up as literal HTML tags on the page instead of a nicely formatted result card. &lt;code&gt;&amp;lt;div class="entry-summary"&amp;gt;&lt;/code&gt; and friends, printed right there as visible text, as if the browser had simply given up on parsing the markup.&lt;/p&gt;

&lt;p&gt;It turned out to be a much more interesting bug than "the HTML is broken." It's a compatibility gap that quietly affects any WordPress plugin that tries to inject rich HTML into a search result excerpt — and it's worth understanding if you've ever built a plugin that hooks into &lt;code&gt;the_excerpt()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The setup
&lt;/h3&gt;

&lt;p&gt;The plugin in question indexes PDF files and injects them into WordPress search results, alongside your regular posts and pages. When a PDF matches a search, it needs to show more than a plain title: a thumbnail preview, the file size, the page count, a highlighted snippet of matching text. None of that fits into what WordPress expects an excerpt to contain, which is, by convention, plain text.&lt;/p&gt;

&lt;p&gt;So the plugin does what a lot of plugins do: it hooks &lt;code&gt;get_the_excerpt&lt;/code&gt;, &lt;code&gt;the_excerpt&lt;/code&gt;, &lt;code&gt;the_content&lt;/code&gt;, and &lt;code&gt;wp_trim_words&lt;/code&gt;, and on those hooks it builds up a chunk of HTML — a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; wrapper with meta info, an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; preview, &lt;code&gt;&amp;lt;mark&amp;gt;&lt;/code&gt; tags around the matched keywords — and returns that HTML as the "excerpt." As long as the active theme calls WordPress's own &lt;code&gt;the_excerpt()&lt;/code&gt;/&lt;code&gt;get_the_excerpt()&lt;/code&gt; functions to render that region of the page, the browser receives real markup and renders it as intended.&lt;/p&gt;

&lt;p&gt;The plugin's changelog showed real engineering effort here — explicit compatibility fixes for Astra, Divi, Avada, and Elementor, plus block-theme support via &lt;code&gt;render_block&lt;/code&gt;. Each of those required understanding how that specific theme reaches into the post data to build its search result cards. That's the part that doesn't get talked about enough: "supports WordPress" and "supports every theme's excerpt rendering path" are not the same claim.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where it broke
&lt;/h3&gt;

&lt;p&gt;One popular multipurpose theme wasn't on that compatibility list, and a customer running it hit exactly the bug described above. Digging into the theme's helper functions turned up the reason, and it's not unique to this theme — it's a pattern worth watching for in general.&lt;/p&gt;

&lt;p&gt;Many big, feature-rich WordPress themes don't just call &lt;code&gt;the_excerpt()&lt;/code&gt; and trust WordPress's own excerpt pipeline. They ship their own excerpt generator — a theme-specific function that reads &lt;code&gt;post_excerpt&lt;/code&gt; or &lt;code&gt;post_content&lt;/code&gt; directly, truncates it to a target length, and outputs it. This is usually done for good reasons: consistent excerpt lengths across templates, custom "read more" behavior, support for excerpts on custom post types that don't behave well with the default logic.&lt;/p&gt;

&lt;p&gt;The problem is that a custom excerpt generator like this typically assumes its input is plain text. So it does something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_post_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'post_content'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$trimmed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_trim_words&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$length&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nf"&gt;esc_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$trimmed&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;esc_html()&lt;/code&gt; call is completely reasonable if the source is plain text. It's a defensive habit — never trust unescaped content into the page. But when the input already contains real HTML markup (because a plugin built it for exactly that purpose), &lt;code&gt;esc_html()&lt;/code&gt; converts every &lt;code&gt;&amp;lt; and &amp;gt; into &amp;amp;lt; and &amp;amp;gt;&lt;/code&gt;. The server sends valid entities, the browser dutifully renders them as literal text, and you get exactly the symptom from the support ticket: a page full of visible tag soup where a nicely formatted card should be.&lt;/p&gt;

&lt;p&gt;Crucially, this bypasses the plugin entirely. The plugin's hooks on &lt;code&gt;get_the_excerpt&lt;/code&gt; and &lt;code&gt;the_excerpt&lt;/code&gt; never fire, because the theme isn't calling those WordPress functions — it has its own code path. All the careful Astra/Divi/Avada/Elementor compatibility work in the world doesn't help, because those fixes assume the theme is at least going through the standard filter chain somewhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  The general lesson
&lt;/h3&gt;

&lt;p&gt;If you're building a WordPress plugin that needs to output HTML through what is conventionally a plain-text field — excerpts are the classic example, but this also applies to things like post titles, meta descriptions, or any "summary" field a theme might touch — keep three things in mind:&lt;/p&gt;

&lt;p&gt;The excerpt filter chain is a convention, not a contract. &lt;code&gt;get_the_excerpt&lt;/code&gt;, &lt;code&gt;the_excerpt&lt;/code&gt;, and &lt;code&gt;wp_trim_words&lt;/code&gt; are the standard way WordPress themes render excerpts, and most themes use them. But nothing forces a theme to. Popular multipurpose and page-builder-adjacent themes especially tend to reimplement this logic for extra control, and when they do, your filters simply never run.&lt;/p&gt;

&lt;p&gt;"It works in theme X" doesn't generalize. Compatibility with WordPress core's rendering pipeline isn't the same as compatibility with a given theme's rendering pipeline. If your plugin injects HTML into what's normally plain-text territory, budget for theme-specific escape hatches — a filter your users (or you) can hook to redirect a theme's custom excerpt function back to &lt;code&gt;the_excerpt()&lt;/code&gt;, for example.&lt;/p&gt;

&lt;p&gt;When something you expect to be markup shows up as literal tag text on the page, suspect double-escaping before you suspect a rendering bug. The moment you see &lt;code&gt;&amp;lt;div class=...&amp;gt;&lt;/code&gt; printed as visible characters rather than parsed as an element, the underlying cause is almost always &lt;code&gt;esc_html()&lt;/code&gt; (or an equivalent) being applied to a string that already contains real HTML — not malformed markup, not a browser quirk. Check the page source directly: if you see &lt;code&gt;&amp;amp;lt;div...&amp;amp;gt;&lt;/code&gt; in the raw response, that confirms it immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this matters beyond one plugin
&lt;/h3&gt;

&lt;p&gt;This class of bug is a nice reminder that WordPress's plugin/theme ecosystem is held together by conventions that are easy to assume are guarantees. The &lt;code&gt;the_excerpt()&lt;/code&gt; filter chain has been part of WordPress for well over a decade, and it's tempting to treat it as a stable, universal integration point. For the vast majority of themes, it is. But "vast majority" isn't "all," and the exceptions tend to be exactly the themes with the largest install bases — the ones that ship enough custom functionality to need their own take on something as basic as an excerpt.&lt;/p&gt;

&lt;p&gt;If you maintain a plugin that hooks core content filters to inject rich output, it's worth explicitly testing against a handful of the biggest multipurpose themes, not just the block-theme defaults — and building in an extension point so users (or you, later) can patch the gap without waiting for a plugin update.&lt;/p&gt;

&lt;p&gt;Have you run into a similar "my HTML got escaped because the theme has its own renderer" bug? I'd be curious to hear which theme did it to you — drop it in the comments.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Indexing Large PDFs Without Timing Out: Background Processing with WP-Cron</title>
      <dc:creator>Webequipe</dc:creator>
      <pubDate>Fri, 31 Jul 2026 11:05:41 +0000</pubDate>
      <link>https://dev.to/webequipe/indexing-large-pdfs-without-timing-out-background-processing-with-wp-cron-published-1cg7</link>
      <guid>https://dev.to/webequipe/indexing-large-pdfs-without-timing-out-background-processing-with-wp-cron-published-1cg7</guid>
      <description>&lt;p&gt;The first version of our indexing code did exactly what you'd expect: a PDF gets uploaded, a hook fires, we parse the whole file, we store the text. Simple, and it worked fine — right up until someone uploaded a 300-page scanned course catalog and the request just… never finished.&lt;/p&gt;

&lt;h3&gt;
  
  
  What we started with
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'add_attachment'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'webequipe_index_pdf_on_upload'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;webequipe_index_pdf_on_upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_attached_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;pathinfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;PATHINFO_EXTENSION&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'pdf'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$parser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\Smalot\PdfParser\Parser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$pdf&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$parser&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parseFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$text&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$pdf&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;getText&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// fine for a 5-page PDF, not for a 300-page one&lt;/span&gt;

    &lt;span class="nf"&gt;webequipe_store_pdf_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a handful of pages this runs in under a second. For a large, text-heavy file it can easily blow past PHP's max_execution_time — 30 seconds by default, and often less on shared hosting. When that happens, the request just gets killed mid-parse. Worst part: this was firing synchronously on the upload request itself, so the admin uploading the file was the one sitting there watching it hang.&lt;/p&gt;

&lt;p&gt;Bumping max_execution_time isn't a real fix. A lot of hosts don't let you override it at all, and even where you can, you're still tying up one PHP worker for the entire parse — which gets worse, not better, the moment two people upload large PDFs at the same time.&lt;/p&gt;

&lt;h3&gt;
  
  
  The actual fix: stop trying to do it all at once
&lt;/h3&gt;

&lt;p&gt;Instead of parsing the whole file in one request, we split the work into small chunks and let WP-Cron pick them up one at a time in the background.&lt;/p&gt;

&lt;p&gt;Step one — on upload, just queue it. Don't parse anything yet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'add_attachment'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'webequipe_queue_pdf_for_indexing'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;webequipe_queue_pdf_for_indexing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_attached_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;pathinfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;PATHINFO_EXTENSION&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'pdf'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;webequipe_set_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'scheduled'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;wp_next_scheduled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'webequipe_process_pdf_chunk'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;wp_schedule_single_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'webequipe_process_pdf_chunk'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upload request now finishes instantly. The actual parsing hasn't started — it's just been scheduled.&lt;/p&gt;

&lt;p&gt;Step two — the cron job processes a fixed number of pages, then reschedules itself if there's more to do.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WEBEQUIPE_PAGES_PER_RUN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'webequipe_process_pdf_chunk'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'webequipe_process_pdf_chunk_handler'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;webequipe_process_pdf_chunk_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$file&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_attached_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_webequipe_page_offset'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;webequipe_set_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$parser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\Smalot\PdfParser\Parser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$pdf&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$parser&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parseFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$pages&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$pdf&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPages&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$total&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$pages&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="no"&gt;WEBEQUIPE_PAGES_PER_RUN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$offset&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;webequipe_store_page_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;getText&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;update_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_webequipe_page_offset'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$end&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$end&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;wp_schedule_single_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'webequipe_process_pdf_chunk'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;webequipe_set_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'indexed'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;delete_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_webequipe_page_offset'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty pages at a time, every few seconds, until the whole document is indexed. No single request ever does enough work to risk a timeout, no matter how large the file is.&lt;/p&gt;

&lt;p&gt;One honest caveat on the snippet above: re-parsing the entire PDF on every chunk just to reach the next offset is wasteful for very large files — in practice you'd want to cache the parsed object or extract page references once rather than re-running parseFile() on every cron tick. I simplified that part here to keep the example readable; it's the kind of optimization that matters more once you're indexing hundreds of large files a day than it does for a single one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tracking progress so the admin isn't guessing
&lt;/h3&gt;

&lt;p&gt;None of this is useful if nobody can tell what's actually happening to their file. We track status per attachment in a dedicated table rather than relying on post meta alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="n"&gt;webequipe_pdf_search_files&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attachment_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'not_indexed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;total_pages&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;indexed_pages&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="nb"&gt;DATETIME&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;attachment_id&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attachment_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That status column is what powers the Manage PDFs screen — every file sits in one of: Not Indexed, Scheduled, Processing, Indexed, Stalled, Error, or Excluded. "Stalled" specifically exists to catch the case where a scheduled cron event never actually ran — which brings up the one WP-Cron quirk worth knowing about.&lt;/p&gt;

&lt;h3&gt;
  
  
  WP-Cron isn't a real cron
&lt;/h3&gt;

&lt;p&gt;WordPress's cron system doesn't run on a timer in the background the way a system cron job does — it only fires when someone visits the site. On a low-traffic site, that means a scheduled indexing job can sit waiting for a while if nobody happens to load a page.&lt;/p&gt;

&lt;p&gt;For anything beyond small personal sites, we point people toward disabling WP-Cron's default trigger and hitting wp-cron.php on a real system cron instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wp-config.php&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DISABLE_WP_CRON'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;bash&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# real system cron, every 5 minutes
&lt;/span&gt;*/&lt;span class="m"&gt;5&lt;/span&gt; * * * * &lt;span class="n"&gt;curl&lt;/span&gt; -&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;example&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/&lt;span class="n"&gt;wp&lt;/span&gt;-&lt;span class="n"&gt;cron&lt;/span&gt;.&lt;span class="n"&gt;php&lt;/span&gt; &amp;gt; /&lt;span class="n"&gt;dev&lt;/span&gt;/&lt;span class="n"&gt;null&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;&amp;amp;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a small config change, but it's the difference between "indexing happens promptly" and "indexing happens whenever someone next visits the site."&lt;/p&gt;

&lt;h3&gt;
  
  
  What this actually bought us
&lt;/h3&gt;

&lt;p&gt;Visitors never notice any of this — search queries run against whatever's already been indexed, and indexing itself never touches a front-end page load. On the admin side, even a huge scanned archive just ticks along in the background instead of hanging a browser tab or silently failing halfway through.&lt;/p&gt;

&lt;p&gt;The bigger lesson generalizes past PDFs, honestly: if a task's runtime scales with user-supplied input — file size, record count, whatever — assuming one request can always finish it is asking for a timeout eventually. Chunking with an explicit, inspectable progress state turned out to be a lot more robust than just hoping the PHP process would have enough time.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
