- 2026-05-08
- posted by Thuta Yar Moe
- System
Automating Daily Vocabulary Posts with Laravel, Structured AI, and the Facebook Graph API
Why automate this
Posting a daily English-learning card on Facebook sounds tiny β pick a phrase, write a meaning in Burmese, add three example sentences, attach an illustration, publish. Multiply that by 365 and the only sustainable answer is to automate the entire pipeline. The objective for this project was exactly that: fully automate generation, persistence, and Facebook publishing of daily learning content, on a stack we already trust β Laravel Actions, structured AI agents, image generation, and the Facebook Graph API.
System architecture
The system is split into three deliberately thin layers so each one can be tested and replaced in isolation.
- Application layer.
GenerateDailyVocabularyis the orchestrator action. TheDailyVocabularyEloquent model stores the final record.FacebookServiceowns the publish call and nothing else. - AI layer. An
EnglishTeacheragent returns structured fields (not free text). Image generation reuses a phrase-aligned prompt produced by the same agent, so the picture and the wording stay coherent. The output format is schema-driven from the start. - External layer. The Facebook Graph API endpoint
/{page-id}/photosis the only outbound integration. It needs a valid Page token and the right permissions, and any error is surfaced through the Laravel log so we can diagnose without re-running the agent.
The data contract
The agent response is treated like an API payload, not a chat reply. The schema has five tagged fields: phrase, content_type, localized_meaning, example_sentences (exactly 3), and image_prompt.
content_type uses a strict allow-list: phrasal_verb, idiom, collocation, expression, noun, verb, adjective, adverb. That single field then drives:
- the dynamic post title (e.g. "Phrasal Verb of the Day"),
- the type-specific hashtag,
- and the layout choices in the caption builder.
Forcing example_sentences to be exactly three keeps the post layout predictable, which means the caption template never has to branch on count. Schema constraints like these are the single biggest reason we can skip brittle parsing logic and runtime formatting fallbacks.
End-to-end flow
The action runs eight steps in a fixed order:
- Trigger. The route
/ai/daily-vocabularyinvokesGenerateDailyVocabulary. - Generate. Request a structured payload from
EnglishTeacher. - Deduplicate. Check whether the phrase already exists using a case-insensitive query.
- Create image. Render a square image using the agent's
image_prompt. - Store image. Save it to the
publicdisk and capture the URL/path. - Persist. Insert phrase, content type, meaning, examples, prompts, and image URL into the database.
- Build caption. Pick a style variant and format the sections.
- Publish. Upload the image and caption to the Graph API.
Caption generation
The caption builder has two halves: a strict format spec and a small safety net.
Required behaviors
- Header:
{Type} of the Day: {Phrase}. - A Burmese meaning block with a labeled section.
- An examples block with a blank line between each example, so it reads cleanly on Facebook.
- Style variants rotate (emoji header, bullet examples, a CTA, a base hashtag set) so the feed never looks copy-pasted.
- The type hashtag is mapped from
content_type.
Fallback and safety
- Any invalid
content_typefalls back toexpressioninstead of throwing. - The mapping uses a strict allow-list, so we cannot accidentally publish a label like "Verb_phrase of the Day" if the agent drifts.
- Every final post still includes the campaign hashtags plus the type hashtag.
A real generated post
This is a verbatim runtime sample of what gets pushed to the Page:
π Phrasal Verb of the Day: Run Out Of
π²π² Meaning (α‘ααααΉαα¬ααΊ):
αα
αΊαα―αα― αα―ααΊαα½α¬αΈαααΊ (ααα―α·) ααΌααΊαααΊαα½α¬αΈαααΊα
π Examples (α₯ααα¬ α
α¬ααΌα±α¬ααΊαΈαα»α¬αΈ):
πΉ We have run out of milk, so I need to go to the grocery store.
πΉ We need to make a decision soon because we are running out of time.
πΉ My phone just ran out of battery, so I could not call you.
β¨ Save this post and try using it in your conversation today!
#LearnEnglishDaily #PhrasalVerbOfTheDay
Database and storage
The DailyVocabulary row keeps everything we need to either re-render the post later or audit what the agent produced:
phrase(unique)content_typelocalized_meaningexample_sentences(stored as JSON)image_promptimage_url
The image file itself lives under storage/app/public/vocabulary. The public URL is generated from the disk configuration, and the relative path is what we hand to the Facebook upload as the multipart source.
Facebook integration
The publish call is intentionally small.
- Endpoint:
https://graph.facebook.com/v25.0/{page-id}/photos - Multipart payload:
-
sourceβ the image binary, captionβ the final formatted text,access_tokenβ the Page token.
-
- Configuration is read from config/services.php, which in turn reads
.env. - Success returns the Facebook response JSON and the action continues gracefully.
- Failure logs both the HTTP status and the response body, so diagnosing a bad token or a missing permission does not require re-running the agent.
Permissions and Meta compliance
The Page token needs:
pages_show_listpages_manage_posts
Avoid the deprecated publish_actions permission β it is no longer accepted. On the Meta App side, the app must also expose:
- a public HTTPS Privacy Policy URL,
- a Data Deletion Instructions or Callback URL,
- and no
localhostor private URLs in app settings.
These three are easy to forget on the first submission and are the most common reason the app gets rejected during review.
Demo runbook
The exact sequence used for a live demo:
- 1) php artisan migrate
- 2) php artisan serve
- 3) Open: http://127.0.0.1:8000/ai/daily-vocabulary
- 4) php artisan tinker
- App\Models\DailyVocabulary::latest()->first(['phrase','content_type','image_url']);
- 5) tail -f storage/logs/laravel.log
During the walkthrough it helps to point out three things in order: the generated card in the browser, the matching DB row with content_type and image_url, and the success/failure entry in the log for the Facebook step.
Lessons learned
- Schema-first AI removes whole classes of bugs. Treating the agent response like an API payload (
phrase,content_type,localized_meaning,example_sentences,image_prompt) means the caption builder never has to guess. - Allow-lists beat string matching. A strict
content_typeallow-list with anexpressionfallback keeps the post header sane even when the model drifts. - Log the bad path loudly. Logging the Graph API status and response body on failure turns "the cron broke" into a 30-second diagnosis instead of a re-run.