News
The press section: articles, their categories, and the editorial endpoints behind them. Content was migrated from the retired Yii site — 210 stories spanning 2018–2026, with every image copied onto our own storage.
Reading is public; writing sits behind auth:sanctum and a permission.
Languages
title, intro and content are translatable. The public endpoints return
the current locale's string, negotiated from Accept-Language
(uz, ru, uz_cyrl; uz when nothing matches). The admin endpoints return
the whole translation map, so an edit form arrives filled in.
A public list only returns stories that exist in the requested language. Showing a card with an empty headline would be worse than showing nothing.
Latin and Cyrillic Uzbek are the same language, so a story written in only one
of them is converted into the other at import time and flagged
(is_machine_translated). Russian is never converted — that would be
translation, not transliteration — so 13 Russian-only stories appear in Russian
alone.
Send the Cyrillic tag in any casing — uz_cyrl, uz-cyrl, uz-Cyrl and
UZ-CYRL all resolve to uz_cyrl, which is what Content-Language echoes
back.
Public endpoints
GET /api/v1/posts
Paginated list, newest first. Follows the list contract.
| Filter | Values | Notes |
|---|---|---|
filter[category] |
category slug | tenderlar, statistic, … — several may be comma-separated |
filter[search] |
free text | matches title in the current locale |
filter[year] |
e.g. 2026 |
publication year as the reader sees it — see Dates |
filter[is_featured] |
1 / 0 |
the editorially highlighted stories |
filter[publication_status] is admin-only: the public endpoint rejects it
with 400, because a draft can never be public anyway.
sort: published_at (default -published_at), id. Prefix with - to reverse.
per_page defaults to 12, capped at 100.
Only stories that are published, whose published_at has passed, and which
exist in the current locale are returned.
{
"id": 42,
"slug": "paxta-2026-yil-xarajatlari",
"title": "2026 yil paxta xomashyosi yetishtirish xarajatlari",
"intro": "Jamg'arma mablag'laridan yetishtiriladigan paxta va g'alla…",
"published_at": "2026-07-05T19:00:00+00:00",
"is_featured": false,
"cover_url": "https://api.agrofin.uz/storage/12/paxta.jpg",
"is_machine_translated": false,
"view_count": 4438,
"categories": [{ "id": 2, "slug": "tenderlar", "title": "Tenderlar", "parent_id": 1 }]
}
is_machine_translated is true when this language's text was produced by
Latin↔Cyrillic transliteration rather than written by an editor. Show it or
don't — but the flag is never hidden from you.
GET /api/v1/posts/{slug}
One article. Adds two fields to the shape above:
content— sanitised HTML (see Body markup);related— up to three published stories sharing a category.
Every read increments view_count. 404 when the slug is unknown, the story
is a draft, or it has no text in the requested language.
GET /api/v1/news-categories
Active categories with the number of published posts in the current locale.
Unpaginated — there are six. parent_id is null for the root
(yangiliklar); every other category hangs off it.
Dates
published_at is an ISO 8601 instant, stored in UTC. It must be rendered in
Asia/Tashkent — every imported story sits at exactly 19:00:00Z, which is
exactly midnight in Tashkent, so formatting the UTC part shows the whole archive
one day early.
filter[year] follows the same rule: it matches the year in the display
timezone (config('news.display_timezone'), default Asia/Tashkent), so a story
shown as 01.01.2026 is found under 2026 and not under 2025.
Body markup
content is HTML, restricted to a fixed allowlist:
p br strong b em i u a[href|title|target] h2 h3 h4
ul ol li table thead tbody tfoot tr td[colspan|rowspan] th[colspan|rowspan]
img[src|alt|title] hr blockquote
Everything else is stripped on write — style, class, <span>, <font>,
<script>, on*= handlers and javascript: URLs never reach the client.
<h1> is demoted to <h2> so the page keeps a single top-level heading.
Tables survive intact: the subsidy figures live in them.
img sources always point at our own storage. No article depends on a host we
do not control.
Admin endpoints
All require auth:sanctum. super_admin bypasses every check.
Putting a story on the site is a separate ability from writing it. This is
a government site: update posts lets an editor rewrite an article, but only
publish posts may move it onto — or off — the public site. The check fires on
the transition, so rewriting an already-published article needs no extra
right; moving draft → published or published → draft does. A blocked
attempt returns 403, never a silent no-op.
| Method | Path | Ability |
|---|---|---|
GET |
/api/v1/admin/posts |
view posts |
POST |
/api/v1/admin/posts |
create posts |
GET |
/api/v1/admin/posts/{post} |
view posts |
PUT |
/api/v1/admin/posts/{post} |
update posts |
DELETE |
/api/v1/admin/posts/{post} |
delete posts |
POST |
/api/v1/admin/posts/{post}/cover |
update posts |
DELETE |
/api/v1/admin/posts/{post}/cover |
update posts |
GET |
/api/v1/admin/news-categories |
view news categories |
POST |
/api/v1/admin/news-categories |
manage news categories |
PUT DELETE |
/api/v1/admin/news-categories/{newsCategory} |
manage news categories |
Reading categories is split from changing them so a read-only role still sees a complete News screen, filters included.
Full ability list: view posts, create posts, update posts,
delete posts, publish posts, view news categories,
manage news categories.
The admin list shows every status and accepts
filter[publication_status]=draft|published|archived alongside the public
filters.
Writing a post
{
"title": { "uz": "Sarlavha", "ru": "Заголовок", "uz_cyrl": "Сарлавҳа" },
"intro": { "uz": "Qisqacha" },
"content": { "uz": "<p>Matn</p>" },
"publication_status": "published",
"published_at": "2026-09-01T00:00:00Z",
"is_featured": false,
"category_ids": [2, 5]
}
| Field | Rule |
|---|---|
title |
required; must include the default locale (uz); unknown locales rejected |
publication_status |
draft, published, archived — moving into or out of published needs publish posts |
published_at |
required when publishing — without it the story can never satisfy published_at <= now() and would silently vanish |
slug |
optional; derived from the title when omitted, de-duplicated with -2, -3 |
intro |
optional; an empty locale is filled from that locale's body |
content |
sanitised on write |
category_ids |
replaces the whole set |
PUT is a partial update: fields you omit keep their stored value. When an
editor rewrites a locale's title or content, that locale is removed from
generated_locales — the "unchecked machine text" badge clears itself.
DELETE soft-deletes. The row stays recoverable.
Cover image
POST …/cover takes multipart cover: jpg / jpeg / png / webp, up to 10 MB.
The collection holds one file, so an upload replaces the previous image.
Migrated content
php artisan news:import-legacy --dump=<file.sql> reads a mysqldump of the
old database directly — no MySQL server needed. It is idempotent: rows are
keyed by the legacy lang_hash, so re-running updates instead of duplicating,
and nothing is ever deleted or truncated. --dry-run rolls everything back;
--skip-media leaves images alone.
php artisan news:restore-legacy-media is the follow-up for images: it
downloads the file behind every post media row that still has a source_url
but no file on disk (preferring the full-size copy over the _low thumbnail
the old site used in listings), then walks every body that still embeds a
picture from agrofin.uz or telegra.ph and replaces it with our own copy. Both
passes are idempotent; anything that cannot be fetched is listed, not fatal.
The old host only answers over plain http://.
Two details worth knowing when reading migrated rows:
legacy_slugskeeps the old per-language URLs (paxta_2026/002/ru), so inbound links can be redirected later. The live slug is a single language-independent one.- The old
langcolumn is wrong on six rows, so the importer decides each row's language from its script instead. Russian is taken at face value — it has no second alphabet to be confused with. - A missing Uzbek alphabet is filled from the other one via
App\Support\UzbekTransliteratorand recorded ingenerated_locales, which is whatis_machine_translatedreports. 29 of the 210 stories carry it. An editor rewriting that locale'stitleorcontentclears the flag.