<?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: motorcarapp</title>
    <description>The latest articles on DEV Community by motorcarapp (@motorcarapp).</description>
    <link>https://dev.to/motorcarapp</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%2F4213563%2F12b5dd66-cd27-49e3-a113-430dafa38890.jpg</url>
      <title>DEV Community: motorcarapp</title>
      <link>https://dev.to/motorcarapp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/motorcarapp"/>
    <language>en</language>
    <item>
      <title>One table, two scheduled jobs: a lightweight way to automate company page posts</title>
      <dc:creator>motorcarapp</dc:creator>
      <pubDate>Wed, 28 Oct 2026 17:26:48 +0000</pubDate>
      <link>https://dev.to/motorcarapp/one-table-two-scheduled-jobs-a-lightweight-way-to-automate-company-page-posts-3k1c</link>
      <guid>https://dev.to/motorcarapp/one-table-two-scheduled-jobs-a-lightweight-way-to-automate-company-page-posts-3k1c</guid>
      <description>&lt;p&gt;We run &lt;a href="https://motorcar.ae" rel="noopener noreferrer"&gt;motorcar.ae&lt;/a&gt;, a car marketplace in the UAE. Some of our social posting was already automated, but our LinkedIn company page was still updated by hand. When we automated it, the most useful part of the work turned out to be deciding what not to build.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The code below is a simplified example written for this post. Names, fields and times are illustrative, not our production setup.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The first design was far too big
&lt;/h2&gt;

&lt;p&gt;The first plan had separate tables for channels, schedules, posts, per platform delivery, the items each post used, and metrics. It covered approval flows, multiple accounts and analytics.&lt;/p&gt;

&lt;p&gt;The business side said no, and then said no again to a smaller version. What they actually needed was short:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one place to see what is planned and what already went out&lt;/li&gt;
&lt;li&gt;no extra load on a production server&lt;/li&gt;
&lt;li&gt;no repeats of the same item in a short period&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One table for the plan and the history
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'scheduled_posts'&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="kt"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'channel'&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="c1"&gt;// linkedin, facebook, ...&lt;/span&gt;
    &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'state'&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'planned'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// planned, posted, skipped, failed&lt;/span&gt;
    &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'due_at'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'published_at'&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;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'remote_url'&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;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'note'&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;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;               &lt;span class="c1"&gt;// why it was skipped or failed&lt;/span&gt;
    &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestamps&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 &lt;code&gt;state&lt;/code&gt; column does two jobs. Rows start as &lt;code&gt;planned&lt;/code&gt;, which turns the table into a calendar anyone can read. When a post goes out, the same row becomes &lt;code&gt;posted&lt;/code&gt; with its link. To cancel a post, you set it to &lt;code&gt;skipped&lt;/code&gt;. No admin screen needed on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two scheduled commands and no queue
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$schedule&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'posts:plan'&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;weekly&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;The planner&lt;/strong&gt; runs once a week and inserts the coming week's slots. The unique key makes it idempotent: running it twice changes nothing, and rows someone edited by hand are left alone. It never picks the content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The publisher&lt;/strong&gt; runs once a day. On most days it runs one indexed query, finds nothing due and exits. When a row is due, it chooses the content at that moment. That matters for a marketplace: something that sold out last night should not be promoted this morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotation from a JSON column
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$recent&lt;/span&gt; &lt;span class="o"&gt;=&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;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'my_posts'&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;'channel'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'linkedin'&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;'state'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'posted'&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;'published_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;gt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&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;subDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&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;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At one post a day this is cheap. At real volume you would want a join table, but a JSON column keeps a small system to one table.&lt;/p&gt;

&lt;h2&gt;
  
  
  A content check before anything goes public
&lt;/h2&gt;

&lt;p&gt;Automatic posting to a company page needs rules that do not depend on someone remembering them. Ours are simple: informational and business content only, nothing political or religious, nothing offensive, and no claims that could cause legal trouble, such as guarantees.&lt;/p&gt;

&lt;p&gt;The check runs on every piece of text that comes from outside the team, such as titles written by users, and again on the final caption. A match skips the post and records why. It never tries to edit around the problem.&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;function&lt;/span&gt; &lt;span class="n"&gt;failsContentRules&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;$text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$blocked&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="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$blocked&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$word&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'~(?&amp;lt;![\p{L}\p{N}])'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;preg_quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'~'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'(?![\p{L}\p{N}])~iu'&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;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$pattern&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="nv"&gt;$word&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two small lessons here. Escape the patterns with &lt;code&gt;preg_quote&lt;/code&gt;, or a word list entry that contains your delimiter will break the expression. And plain &lt;code&gt;\b&lt;/code&gt; boundaries are not Unicode aware, so letter and number lookarounds give more reliable whole word matching.&lt;/p&gt;

&lt;h2&gt;
  
  
  LinkedIn API lessons
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Commentary is "little text".&lt;/strong&gt; Characters like &lt;code&gt;( ) [ ] { } &amp;lt; &amp;gt; # * _ ~ | @&lt;/code&gt; and the backslash must be escaped, including inside URLs, or posts get cut off or rejected. Hashtags use the &lt;code&gt;{hashtag|\#|Tag}&lt;/code&gt; form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link previews need an uploaded thumbnail.&lt;/strong&gt; LinkedIn does not scrape the page for you. Upload through the Images API first, then reference the image URN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;LinkedIn-Version&lt;/code&gt; header expires.&lt;/strong&gt; Versions are retired after about a year and old ones return HTTP 426. Put a reminder somewhere you will see it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Carousels are &lt;code&gt;multiImage&lt;/code&gt; posts&lt;/strong&gt; with between 2 and 20 uploaded images.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Check who writes the token
&lt;/h2&gt;

&lt;p&gt;OAuth tokens expire and get refreshed. If the web callback that first saves the token and the scheduled job that later refreshes it run under different system users, make sure both can write the token file. Otherwise everything works in testing and fails quietly on the first refresh, weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs to run
&lt;/h2&gt;

&lt;p&gt;One weekly run and one short daily run, a few seconds each. No workers, no polling, no queue.&lt;/p&gt;

&lt;p&gt;If you are about to build something like this, start with one table and make the requirements argue for a second one. Ours never did.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>automation</category>
      <category>api</category>
    </item>
  </channel>
</rss>
