We run motorcar.ae, 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.
The code below is a simplified example written for this post. Names, fields and times are illustrative, not our production setup.
The first design was far too big
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.
The business side said no, and then said no again to a smaller version. What they actually needed was short:
- one place to see what is planned and what already went out
- no extra load on a production server
- no repeats of the same item in a short period
One table for the plan and the history
Schema::create('scheduled_posts', function (Blueprint $t) {
$t->id();
$t->string('channel', 20); // linkedin, facebook, ...
$t->string('state', 20)->default('planned'); // planned, posted, skipped, failed
$t->dateTime('due_at');
$t->dateTime('published_at')->nullable();
$t->string('remote_url')->nullable();
$t->string('note')->nullable(); // why it was skipped or failed
$t->timestamps();
});
The state column does two jobs. Rows start as planned, which turns the table into a calendar anyone can read. When a post goes out, the same row becomes posted with its link. To cancel a post, you set it to skipped. No admin screen needed on day one.
Two scheduled commands and no queue
$schedule->command('posts:plan')->weekly();
The planner 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.
The publisher 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.
Rotation from a JSON column
$recent = DB::table('my_posts')
->where('channel', 'linkedin')
->where('state', 'posted')
->where('published_at', '>=', now()->subDays(30))
->all();
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.
A content check before anything goes public
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.
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.
function failsContentRules(string $text, array $blocked): ?string
{
foreach ($blocked as $word) {
$pattern = '~(?<![\p{L}\p{N}])' . preg_quote($word, '~') . '(?![\p{L}\p{N}])~iu';
if (preg_match($pattern, $text)) {
return $word;
}
}
return null;
}
Two small lessons here. Escape the patterns with preg_quote, or a word list entry that contains your delimiter will break the expression. And plain \b boundaries are not Unicode aware, so letter and number lookarounds give more reliable whole word matching.
LinkedIn API lessons
-
Commentary is "little text". Characters like
( ) [ ] { } < > # * _ ~ | @and the backslash must be escaped, including inside URLs, or posts get cut off or rejected. Hashtags use the{hashtag|\#|Tag}form. - Link previews need an uploaded thumbnail. LinkedIn does not scrape the page for you. Upload through the Images API first, then reference the image URN.
-
The
LinkedIn-Versionheader expires. Versions are retired after about a year and old ones return HTTP 426. Put a reminder somewhere you will see it. -
Carousels are
multiImageposts with between 2 and 20 uploaded images.
Check who writes the token
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.
What it costs to run
One weekly run and one short daily run, a few seconds each. No workers, no polling, no queue.
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.
Top comments (0)