<?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: Dhaval Joshi</title>
    <description>The latest articles on DEV Community by Dhaval Joshi (@dhaval_joshi_c632a2b7868e).</description>
    <link>https://dev.to/dhaval_joshi_c632a2b7868e</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%2Fuser%2Fprofile_image%2F3293004%2Fcb30903b-c0a0-4a30-861c-0765232b35bf.png</url>
      <title>DEV Community: Dhaval Joshi</title>
      <link>https://dev.to/dhaval_joshi_c632a2b7868e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dhaval_joshi_c632a2b7868e"/>
    <language>en</language>
    <item>
      <title>How we turned X posts into a Wall of Love with the X API v2 (Laravel)</title>
      <dc:creator>Dhaval Joshi</dc:creator>
      <pubDate>Thu, 15 Oct 2026 06:39:33 +0000</pubDate>
      <link>https://dev.to/dhaval_joshi_c632a2b7868e/how-we-turned-x-posts-into-a-wall-of-love-with-the-x-api-v2-laravel-fh7</link>
      <guid>https://dev.to/dhaval_joshi_c632a2b7868e/how-we-turned-x-posts-into-a-wall-of-love-with-the-x-api-v2-laravel-fh7</guid>
      <description>&lt;p&gt;When a product launches, the best praise it gets is scattered across X: a thread here, a demo video there, a quote-post with a screenshot. A week later it's buried in the timeline.&lt;/p&gt;

&lt;p&gt;We wanted one page that collects those posts, shows them properly and links back to each original. So we built an &lt;strong&gt;Import from X&lt;/strong&gt; feature into &lt;a href="https://wallsy.live" rel="noopener noreferrer"&gt;Wallsy&lt;/a&gt;, our testimonial tool, and tested it on a real launch: the &lt;a href="https://wallsy.live/w/jev-on-x" rel="noopener noreferrer"&gt;Jev Wall of Love&lt;/a&gt;, which at the time of writing holds 13 posts in three languages with more than 33,000 likes between them.&lt;/p&gt;

&lt;p&gt;This post covers how the import works under the hood: parsing the URL, calling the X API v2, cleaning up the response, and publishing it safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The goal: one paste, one published card
&lt;/h2&gt;

&lt;p&gt;The user flow is deliberately small:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the Inbox, choose &lt;strong&gt;Import from X&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Paste &lt;code&gt;https://x.com/name/status/1234567890&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Import and publish&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Behind that button we need the post text, media, author name, handle and avatar, and the like/reply counts, all turned into a testimonial card that appears on the wall and in any embedded widgets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Get the post ID out of whatever URL people paste
&lt;/h2&gt;

&lt;p&gt;People paste &lt;code&gt;x.com&lt;/code&gt;, &lt;code&gt;twitter.com&lt;/code&gt;, &lt;code&gt;mobile.twitter.com&lt;/code&gt;, &lt;code&gt;/i/web/status/...&lt;/code&gt; links, and links with &lt;code&gt;?s=20&lt;/code&gt; tracking params. One regex handles all of them and returns only the numeric ID:&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="cd"&gt;/** The numeric post id from an x.com / twitter.com status URL, or null. */&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;postIdFromUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$url&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="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'#^https?://(?:www\.|mobile\.)?(?:x|twitter)\.com/(?:[A-Za-z0-9_]{1,15}|i/web)/status(?:es)?/(\d{5,25})(?:[/?\#].*)?$#i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$m&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="kc"&gt;null&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="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&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;Anchoring the pattern (&lt;code&gt;^...$&lt;/code&gt;) and allowing only known hosts means we never send arbitrary user input to the X API, just a validated numeric ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: One API call per post
&lt;/h2&gt;

&lt;p&gt;The X API v2 can return the post, its author and its media in a single request if you ask for the right expansions:&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;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.x.bearer_token'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;acceptJson&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timeout&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://api.x.com/2/tweets/'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'expansions'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'author_id,attachments.media_keys'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'tweet.fields'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'created_at,public_metrics,entities,attachments,note_tweet'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'user.fields'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'name,username,profile_image_url'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'media.fields'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'type,url,preview_image_url,width,height,alt_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;A few things worth knowing if you build something similar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;note_tweet&lt;/code&gt; matters.&lt;/strong&gt; Long posts are truncated in &lt;code&gt;text&lt;/code&gt;. The full body lives in &lt;code&gt;note_tweet.text&lt;/code&gt;, so we prefer that when it's present.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authors and media come back in &lt;code&gt;includes&lt;/code&gt;&lt;/strong&gt;, not inline. You join them back by &lt;code&gt;author_id&lt;/code&gt; and &lt;code&gt;media_key&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Videos and GIFs have no &lt;code&gt;url&lt;/code&gt;&lt;/strong&gt;, only a &lt;code&gt;preview_image_url&lt;/code&gt;. We store the preview and link the play button to the original post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors aren't always non-2xx.&lt;/strong&gt; For a deleted, private or unknown post, X can answer &lt;code&gt;200&lt;/code&gt; with an &lt;code&gt;errors&lt;/code&gt; array and no &lt;code&gt;data&lt;/code&gt;. Check for &lt;code&gt;data&lt;/code&gt;, not just the status code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We map the status codes to messages a non-technical user can act on:&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;XApiException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X is limiting requests right now. Please try again in a few minutes.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;);&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;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;403&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="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Credentials problem: log it for us, show a generic message to the user.&lt;/span&gt;
    &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X API rejected the credentials'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()]);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;XApiException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Wallsy couldn'&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="n"&gt;connect&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nc"&gt;X&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nc"&gt;Please&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;again&lt;/span&gt; &lt;span class="n"&gt;later&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;', 503);
}

$data = $response-&amp;gt;json('&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="s1"&gt;');
if ($response-&amp;gt;failed() || ! is_array($data)) {
    throw new XApiException('&lt;/span&gt;&lt;span class="nc"&gt;That&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="n"&gt;couldn&lt;/span&gt;&lt;span class="s1"&gt;'t be found. It may be deleted, private or from a protected account.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;404&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;Note that a 401/403 never leaks to the user as "your token is invalid". That's our problem, not theirs, so it's logged and reported as a generic 503.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Clean up the response
&lt;/h2&gt;

&lt;p&gt;Two small fixes make the cards look right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bigger avatars.&lt;/strong&gt; &lt;code&gt;profile_image_url&lt;/code&gt; points at a 48px &lt;code&gt;_normal&lt;/code&gt; image, which looks blurry on a card. The 400px version lives at the same path:&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;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_normal.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_400x400.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$author&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'profile_image_url'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Readable links.&lt;/strong&gt; Post text contains &lt;code&gt;t.co&lt;/code&gt; short links, plus a trailing &lt;code&gt;t.co&lt;/code&gt; link for attached media. We swap each short link for its &lt;code&gt;display_url&lt;/code&gt; and drop the media ones, since the media is rendered separately:&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="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$urls&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$replacement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'media_key'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'display_url'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'expanded_url'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&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="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$replacement&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;html_entity_decode&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="no"&gt;ENT_QUOTES&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;ENT_HTML5&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;html_entity_decode&lt;/code&gt; is there because X returns &lt;code&gt;&amp;amp;amp;&lt;/code&gt; and friends in post text. The output is still escaped when it's rendered, so decoding here doesn't open an XSS hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Fetch once, reuse everywhere
&lt;/h2&gt;

&lt;p&gt;X API access is rate-limited and paid, so we store each fetched post in its own &lt;code&gt;x_posts&lt;/code&gt; table, keyed by the post ID. If anyone imports the same post again, we reuse the stored copy instead of calling X:&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;$post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;XPost&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'post_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$postId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Only call X the first time anyone imports this post.&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;$post&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;XPost&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;firstOrCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'post_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$postId&lt;/span&gt;&lt;span class="p"&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="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetchPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$postId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'fetched_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&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;&lt;code&gt;firstOrCreate&lt;/code&gt; keyed on &lt;code&gt;post_id&lt;/code&gt; (with a unique index on that column) keeps two simultaneous imports of the same post from creating duplicates. We also reject a second import of the same post into the same account with a &lt;code&gt;409&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The trade-off: like and reply counts are a snapshot from import time, not live numbers. For a testimonial wall that's fine, and our privacy page says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Publish through the normal path
&lt;/h2&gt;

&lt;p&gt;The imported post becomes a regular testimonial, created inside a transaction and published through the same service that handles testimonials collected by form:&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="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&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="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$business&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$testimonial&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;Testimonial&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'x_post_id'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'author_name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;author_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'job_title'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'@'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;author_username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'rating'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// nobody gave a star rating, so we don't invent one&lt;/span&gt;
        &lt;span class="s1"&gt;'body'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'(Post with media only)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'social_url'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'status'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Testimonial&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;STATUS_APPROVED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'source_meta'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'source'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'x'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'x_post_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;post_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;$testimonial&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;business_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$business&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$testimonial&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&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="n"&gt;audit&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AuditLog&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ACTION_IMPORT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$testimonial&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Applies plan limits and refreshes any embedded widgets.&lt;/span&gt;
    &lt;span class="k"&gt;return&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="n"&gt;publishing&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$testimonial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$actor&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;Reusing the publishing path means we didn't have to re-implement plan limits, audit logging or widget cache refreshes for a new source. The whole feature also sits behind a feature flag, so we could ship it dark and turn it on once it was tested.&lt;/p&gt;

&lt;p&gt;Two deliberate choices here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rating&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;.&lt;/strong&gt; A post isn't a 5-star review, and showing stars nobody gave would be misleading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;social_url&lt;/code&gt; always links back&lt;/strong&gt; to the original post, so anyone can check the source.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What ends up on the wall
&lt;/h2&gt;

&lt;p&gt;Each card shows the full post text (long posts collapse after five lines with "Show more"), up to four images or video previews in a grid, likes and replies, and the author's name, handle and photo with a link to the original. The wall is a masonry layout that loads 50 posts at a time.&lt;/p&gt;

&lt;p&gt;You can see the result here: &lt;a href="https://wallsy.live/w/jev-on-x" rel="noopener noreferrer"&gt;wallsy.live/w/jev-on-x&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on doing this responsibly
&lt;/h2&gt;

&lt;p&gt;Featuring someone's public post is still featuring someone's words. Our rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only import public posts that are genuinely about your product.&lt;/li&gt;
&lt;li&gt;Never edit the text. Imported posts keep their original wording and link to the source.&lt;/li&gt;
&lt;li&gt;Take a post down if its author asks, or if they delete it.&lt;/li&gt;
&lt;li&gt;In the US, the FTC's rule on consumer reviews and testimonials (&lt;a href="https://www.ftc.gov/business-guidance/resources/consumer-reviews-testimonials-rule-questions-answers" rel="noopener noreferrer"&gt;Q&amp;amp;A here&lt;/a&gt;) bans fake or misleading testimonials, and that applies to posts you choose to feature too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The Wall of Love itself is free on Wallsy. Importing posts from X is on the Pro plan. If you want the non-technical walkthrough (setting up the wall, the link, the CTA button and the embed widget), it's on our blog: &lt;a href="https://wallsy.live/blog/wall-of-love-from-x-posts" rel="noopener noreferrer"&gt;How to Build a Wall of Love from X Posts&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I'd like to hear from anyone else building on the X API v2: how are you handling &lt;code&gt;note_tweet&lt;/code&gt;, media previews and the &lt;code&gt;200&lt;/code&gt;-with-errors responses? Let me know in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>twitter</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Stop Hitting Medium's Paywall: Open Articles in Freedium with One Click</title>
      <dc:creator>Dhaval Joshi</dc:creator>
      <pubDate>Wed, 15 Jul 2026 06:24:12 +0000</pubDate>
      <link>https://dev.to/dhaval_joshi_c632a2b7868e/stop-hitting-mediums-paywall-open-articles-in-freedium-with-one-click-25d1</link>
      <guid>https://dev.to/dhaval_joshi_c632a2b7868e/stop-hitting-mediums-paywall-open-articles-in-freedium-with-one-click-25d1</guid>
      <description>&lt;p&gt;As developers, we read a lot.&lt;/p&gt;

&lt;p&gt;Whether it's tutorials, architecture discussions, AI research, or engineering blogs, Medium is home to a huge amount of valuable content. Unfortunately, many articles eventually run into the member-only prompt, interrupting the reading experience.&lt;/p&gt;

&lt;p&gt;I wanted a simpler workflow.&lt;/p&gt;

&lt;p&gt;Instead of manually copying a Medium URL, opening Freedium, and pasting the link every time, I built a Chrome extension that automates the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introducing Freedium – Read Medium Articles Free
&lt;/h2&gt;

&lt;p&gt;This extension detects the current Medium article and lets you open it in &lt;strong&gt;Freedium&lt;/strong&gt; with a single click.&lt;/p&gt;

&lt;p&gt;No copying URLs.&lt;/p&gt;

&lt;p&gt;No switching tabs.&lt;/p&gt;

&lt;p&gt;Just click and continue reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Built It
&lt;/h2&gt;

&lt;p&gt;My normal workflow looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open a Medium article.&lt;/li&gt;
&lt;li&gt;Hit the member-only prompt.&lt;/li&gt;
&lt;li&gt;Open Freedium.&lt;/li&gt;
&lt;li&gt;Paste the article URL.&lt;/li&gt;
&lt;li&gt;Wait for the page to load.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It only takes a minute, but repeating it multiple times a day gets annoying.&lt;/p&gt;

&lt;p&gt;This extension removes those repetitive steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Features
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;🚀 One-click access to Freedium&lt;/li&gt;
&lt;li&gt;⚡ Lightweight Chrome extension&lt;/li&gt;
&lt;li&gt;🧩 No account required&lt;/li&gt;
&lt;li&gt;🔗 Automatically detects the current Medium article&lt;/li&gt;
&lt;li&gt;🎯 Simple and minimal interface&lt;/li&gt;
&lt;li&gt;💻 Open source&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;The extension simply:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Detects when you're viewing a Medium article.&lt;/li&gt;
&lt;li&gt;Retrieves the current page URL.&lt;/li&gt;
&lt;li&gt;Opens the corresponding Freedium page in a new tab.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it.&lt;/p&gt;

&lt;p&gt;No tracking.&lt;/p&gt;

&lt;p&gt;No complex background services.&lt;/p&gt;

&lt;p&gt;Just a small utility that saves a few clicks every day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Install it directly from the Chrome Web Store:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chrome Extension&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://chromewebstore.google.com/detail/freedium-read-medium-arti/fmjhglncijcinacolmmaepghmgcloljf" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/freedium-read-medium-arti/fmjhglncijcinacolmmaepghmgcloljf&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Source Code
&lt;/h2&gt;

&lt;p&gt;The project is completely open source.&lt;/p&gt;

&lt;p&gt;GitHub Repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/therohanparmar/medium-to-freedium" rel="noopener noreferrer"&gt;https://github.com/therohanparmar/medium-to-freedium&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Feel free to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ Star the repository&lt;/li&gt;
&lt;li&gt;🐛 Report issues&lt;/li&gt;
&lt;li&gt;💡 Suggest improvements&lt;/li&gt;
&lt;li&gt;🔀 Submit pull requests&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;The extension is built using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JavaScript&lt;/li&gt;
&lt;li&gt;HTML&lt;/li&gt;
&lt;li&gt;CSS&lt;/li&gt;
&lt;li&gt;Chrome Extensions API (Manifest V3)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keeping the codebase simple makes it easy for anyone to contribute or customize.&lt;/p&gt;

&lt;h2&gt;
  
  
  Future Ideas
&lt;/h2&gt;

&lt;p&gt;Some enhancements I'm considering include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Support for Firefox and Edge&lt;/li&gt;
&lt;li&gt;Keyboard shortcuts&lt;/li&gt;
&lt;li&gt;Context menu integration&lt;/li&gt;
&lt;li&gt;Automatic redirection (optional)&lt;/li&gt;
&lt;li&gt;Better UI and user preferences&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have other ideas, I'd love to hear them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclaimer
&lt;/h2&gt;

&lt;p&gt;This extension does &lt;strong&gt;not&lt;/strong&gt; modify Medium or bypass authentication. It simply streamlines the process of opening the current Medium article in Freedium. Whether an article is accessible depends on Freedium's service and Medium's policies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Sometimes the best developer tools aren't large frameworks or AI assistants—they're tiny utilities that remove repetitive friction from your daily workflow.&lt;/p&gt;

&lt;p&gt;If you're someone who frequently reads Medium articles, I hope this extension saves you a few clicks and makes your reading experience a little smoother.&lt;/p&gt;

&lt;p&gt;If you find it useful, consider giving the project a ⭐ on GitHub or contributing to the repository.&lt;/p&gt;

&lt;p&gt;Happy reading! 📚&lt;/p&gt;

</description>
      <category>automation</category>
      <category>productivity</category>
      <category>showdev</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>Introducing CursorNinja – One Script to Install, Update &amp; Setup the Cursor Editor!</title>
      <dc:creator>Dhaval Joshi</dc:creator>
      <pubDate>Wed, 25 Jun 2025 07:44:07 +0000</pubDate>
      <link>https://dev.to/dhaval_joshi_c632a2b7868e/introducing-cursorninja-one-script-to-install-update-setup-the-cursor-editor-2889</link>
      <guid>https://dev.to/dhaval_joshi_c632a2b7868e/introducing-cursorninja-one-script-to-install-update-setup-the-cursor-editor-2889</guid>
      <description>&lt;p&gt;I just open-sourced CursorNinja 🚀&lt;br&gt;
A clean and reliable Bash-based utility to simplify your Cursor Editor setup &amp;amp; updates.&lt;/p&gt;

&lt;p&gt;🔧 What it does:&lt;/p&gt;

&lt;p&gt;Installs the latest or specified version of Cursor&lt;/p&gt;

&lt;p&gt;Auto-checks existing installation and upgrades smoothly&lt;/p&gt;

&lt;p&gt;Works across macOS and Linux&lt;/p&gt;

&lt;p&gt;One-liner install method ready!&lt;/p&gt;

&lt;p&gt;🛠️ Perfect for developers who want to:&lt;/p&gt;

&lt;p&gt;Reinstall after system format&lt;/p&gt;

&lt;p&gt;Share setup scripts in teams&lt;/p&gt;

&lt;p&gt;Use Cursor AI locally with consistent environments&lt;/p&gt;

&lt;p&gt;Try it now 👉 &lt;a href="https://github.com/vcian/CursorNinja" rel="noopener noreferrer"&gt;https://github.com/vcian/CursorNinja&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Contributions, stars ⭐ and feedback are welcome!&lt;/p&gt;

</description>
      <category>linux</category>
      <category>opensource</category>
      <category>beginners</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
