Standar Penulisan Kode
Halaman ini menjelaskan apa yang ditegakkan secara otomatis oleh Pint dan PHPStan, serta convention yang tidak dapat diperiksa tool tetapi tetap akan ditanyakan saat review. Gunakan referensi ini sebelum membuka pull request, atau ketika tool menolak kode yang menurut Anda benar. Dua kasus di bawah bahkan menunjukkan situasi saat tool benar tetapi perbaikan yang terlihat paling jelas justru salah.
Contoh minimal
composer format # vendor/bin/pint — memperbaiki
composer format-check # vendor/bin/pint --test — hanya melaporkan, ini yang dijalankan CI
composer analyse # vendor/bin/phpstan analyse --memory-limit=1G
npm run format # prettier --write
npm run lint:fix # eslint --fix
npm run typecheck # vue-tsc --noEmit2
3
4
5
6
7
Semua file dalam repository harus lolos keenam pemeriksaan tersebut. Pull request yang belum lolos belum siap direview. Jalankan composer ci dan npm run ci untuk memeriksa semuanya melalui dua command.
Style: Pint
Isi lengkap pint.json:
{
"preset": "laravel",
"rules": {
"declare_strict_types": true,
"ordered_imports": {
"sort_algorithm": "alpha"
},
"no_unused_imports": true
},
"exclude": [
"integration",
"vendor"
]
}2
3
4
5
6
7
8
9
10
11
12
13
14
Laravel preset digunakan bersama tiga rule tambahan:
| Rule | Dampak |
|---|---|
declare_strict_types | Setiap file PHP harus diawali declare(strict_types=1);. Ini rule, bukan sekadar convention. |
ordered_imports dengan alpha | Statement use diurutkan secara alfabetis agar diff import hanya membahas import-nya. |
no_unused_imports | Import yang tidak digunakan dihapus. |
Bagian awal setiap source file seharusnya terlihat seperti ini:
<?php
declare(strict_types=1);
namespace PandaPanel\Tables\Columns;
use Closure;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Tables\Enums\SortDirection;2
3
4
5
6
7
8
9
exclude masih menyebut integration, directory lama yang pernah berisi copy application asal framework dan sudah dihapus. Entry tersebut tidak berbahaya dan dibiarkan karena menghapusnya tidak mengubah behavior apa pun.
.editorconfig menangani bagian yang tidak dicakup Pint — frontend, config, dan docs:
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.{yml,yaml,json,neon}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false2
3
4
5
6
7
8
9
10
11
12
13
Markdown mempertahankan trailing whitespace karena dua spasi di akhir baris bermakna line break. Menghapusnya dapat mengubah layout paragraph secara diam-diam.
Static analysis: PHPStan
Isi lengkap phpstan.neon:
includes:
- vendor/larastan/larastan/extension.neon
parameters:
level: 4
paths:
- src
- database
tmpDir: build/phpstan2
3
4
5
6
7
8
9
| Key | Value | Alasan |
|---|---|---|
includes | larastan/extension.neon | Mengajarkan PHPStan tentang Eloquent, container, facade, dan helper Laravel. |
level | 4 | Level 5 menambahkan pemeriksaan "view-string" yang tidak dapat me-resolve package view bernamespace (panda-panel::*) karena service provider tidak boot saat analysis. |
paths | src, database | tests/ dan examples/ tidak dianalisis. |
tmpDir | build/phpstan | Cache berada di build/ yang di-gitignore, sehingga checkout tidak membutuhkan empty tracked directory. |
Komentar di config menjelaskan syarat untuk menaikkan level: lakukan hanya setelah tidak ada lagi reference ke namespaced package view. Alasan ini tertulis sebagai constraint, bukan preferensi pribadi.
CI menjalankan analyser dua kali, pada dua ujung compatibility range — Laravel 12 + PHP 8.2 dan Laravel 13 + PHP 8.4. Alasannya kedua ujung dapat berbeda dalam API yang tersedia. toPasswordRulesString() misalnya tersedia di Laravel 13 tetapi tidak di 12. Menganalisis hanya versi tertinggi dapat melewatkan call yang merusak versi terendah; menganalisis hanya versi terendah dapat melaporkan guard yang tidak diperlukan pada versi tertinggi. Version guard harus ditulis agar keduanya bersih.
Karena tests/ tidak termasuk paths, trait yang hanya digunakan fixture test dapat dianggap unused dari sudut pandang src/. Letakkan shared behavior pada source yang benar-benar menggunakannya atau base class, bukan menambahkan ignore.
Dua jebakan dari tool
class-string<Resource> berubah menjadi class-string<resource>
Fixer phpdoc_types milik Pint menormalisasi nama scalar type dalam docblock, dan resource adalah type bawaan PHP. Docblock yang bermaksud menunjuk class Resource milik framework dapat diubah menjadi lowercase lalu berarti PHP resource type:
// Salah: pada run berikutnya Pint mengubahnya menjadi class-string<resource>.
use PandaPanel\Resources\Resource;
/** @param class-string<Resource> $resource */2
3
4
Import base class menggunakan alias. Pint tidak mengubah nama yang tidak dikenalnya:
use PandaPanel\Resources\Resource as PanelResource;
/** @param class-string<PanelResource> $resource */2
3
FQCN dengan leading slash juga dapat lolos dari fixer, tetapi justru lebih buruk: fully_qualified_strict_types dapat menambahkan kembali use statement yang tidak digunakan. Hasilnya adalah unused import dan FQCN yang IDE anggap dapat disederhanakan. Menonaktifkan phpdoc_types memang menyelesaikan masalah, tetapi mengorbankan normalisasi scalar type di seluruh project. Alias adalah solusi lokal yang benar dan juga berlaku untuk ResourceConfiguration dalam PandaPanel\Resources.
Request::query('a.b') tidak membaca nested path
Query bag mencari string lengkap sebagai satu key. Jadi dotted path dibaca sebagai literal key dan biasanya menghasilkan null. State table bernamespace — misalnya relation table menulis ke relations[posts][page] — harus dibaca dengan data_get():
data_get($request->query(), $path);Convention yang tidak dapat diperiksa tool
Setter menggunakan nama sederhana, reader memakai prefix get
Ini dicatat sebagai keputusan D9 dalam ADR. PHP tidak mendukung overload, dan framework sengaja menghindari getter/setter gabungan yang berubah behavior berdasarkan argument:
$panel->id('admin')->path('admin')->auth();
$panel->getId(); // 'admin'
$panel->getPath(); // 'admin'
$panel->getMiddleware(); // list<string>2
3
4
5
Hanya scalar dan array yang boleh melintasi boundary ke Vue
Ini adalah kontrak serialization, bukan sekadar style preference. Schema men-serialize column, filter, field, layout, action, widget, dan navigation menjadi scalar dan array. Closure dievaluasi saat serialization dan hanya hasilnya yang dikirim:
use App\Models\Post;
use PandaPanel\Actions\Action;
Action::make('publish')->visible(static fn (?Post $record): bool => $record?->draft === true);2
3
4
Action::visible(Closure $callback): static menyimpan closure di server. Vue hanya menerima array Action dengan button muncul atau tidak — tidak pernah closure, SQL, internal policy, atau class name.
Ada test yang memastikan class name tidak pernah masuk page metadata atau shared props. Jika serialized array mendapat key baru, TypeScript interface harus diperbarui pada perubahan yang sama. Sisi Vue melakukan discrimination berdasarkan type dengan exhaustive never check, sehingga PHP type tanpa renderer menjadi compile error, bukan cell kosong.
discover*() dan navigationGroups() bersifat accumulating
Method-method tersebut menambahkan value, bukan menimpa. Single-path implementation akan menjadi dead-end untuk module system yang ingin berkontribusi ke Panel yang tidak dibuat oleh module itu sendiri. Test mendaftarkan dua discovery path pada satu Panel dan memastikan keduanya tetap tersedia.
Jangan melakukan reflection atau filesystem scanning di request path
Discovery berjalan saat boot atau satu kali melalui panel:cache. Finder di dalam controller berarti Finder berjalan setiap page load.
Jangan membangun Tailwind class melalui interpolation
md:col-span-${n} tidak akan ada di bundle jika string class tersebut tidak pernah muncul literal pada file yang discan Tailwind. Column span, badge color, grid column, dan content width harus dipetakan melalui literal record:
const SPANS: Record<number, string> = {
1: 'md:col-span-1',
2: 'md:col-span-2',
3: 'md:col-span-3',
4: 'md:col-span-4',
};2
3
4
5
6
Validasi value dari PHP, jangan hanya meng-assert
Guard function harus mempersempit incoming payload agar shape mismatch turun secara aman menjadi empty cell, bukan melempar exception di dalam table. types/cellGuards.ts, types/widgetGuards.ts, dan composables/usePanelPage.ts adalah pattern yang harus diikuti.
Style frontend
Prettier bertanggung jawab atas formatting. .prettierrc.json:
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 4,
"useTabs": false,
"printWidth": 80,
"vueIndentScriptAndStyle": false,
"endOfLine": "lf",
"overrides": [
{ "files": ["*.json", "*.yml", "*.yaml"], "options": { "tabWidth": 2 } }
]
}2
3
4
5
6
7
8
9
10
11
12
13
.prettierignore mengecualikan build, node_modules, vendor, bootstrap, examples, resources/views, dan resources/js/components/ui. Directory terakhir berasal dari shadcn-vue dan sengaja dibiarkan mengikuti formatting upstream karena file di sana paling mungkin diambil ulang dari shadcn-vue. Reformatting lokal hanya akan membuat setiap update upstream menjadi whitespace diff.
ESLint menangani kesalahan yang dapat lolos type-check tetapi tetap salah. eslint-config-prettier ditempatkan paling akhir untuk mematikan seluruh stylistic rule yang tumpang tindih dengan Prettier. Enam override dalam eslint.config.js:
| Rule | Setting | Alasan |
|---|---|---|
vue/multi-word-component-names | off | Nama seperti PanelSidebar, DataTable, ActionModal adalah vocabulary framework; rule tidak memahami konteks tersebut. |
vue/no-mutating-props | error | Prop yang dikirim server adalah data. Menyalin semuanya ke local state hanya demi memuaskan rule membuat form memiliki dua sumber kebenaran. |
@typescript-eslint/no-unused-vars | error, mengabaikan ^_ | Argument unused dengan prefix _ berarti signature sengaja dipenuhi. |
@typescript-eslint/no-explicit-any | error | Payload masuk sebagai untyped JSON dan harus dipersempit manual. Input-nya adalah unknown; any berarti melewati boundary validation. |
vue/no-v-html | off | Hanya ada satu penggunaan pada Markdown preview dan aman karena renderMarkdown() meng-escape semua character sebelum menambah satu tag. Rule dimatikan global karena disable comment tidak praktis pada multi-line attribute. |
vue/require-default-prop | off | Rule dibuat untuk Options API. Pada defineProps<Props>(), class?: string tanpa default adalah bentuk yang benar; default '' akan menambahkan empty attribute ke setiap element. |
Dua directory memiliki relaxation tambahan: frontend/host/** memperbolehkan empty component block karena stub memang harus minimal; resources/js/components/ui/** mematikan empat rule karena alasan yang sama dengan Prettier.
TypeScript berjalan dalam strict mode:
"strict": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true2
3
4
5
6
7
8
verbatimModuleSyntax berarti type-only import harus ditulis eksplisit:
import type { Plugin } from 'vite';
import { defineConfig } from 'vite';2
Perubahan yang tidak pernah hanya satu edit
Beberapa jenis perubahan selalu memiliki sekumpulan file yang harus bergerak bersama. Mengubah hanya setengahnya adalah salah satu penyebab pull request paling sering dikembalikan:
| Perubahan | Juga membutuhkan |
|---|---|
| Column, field, entry, atau widget type baru | PHP class, enum case, dan branch di Vue renderer — union bersifat exhaustive. |
Field baru pada NavigationItem | Constructor, seluruh with*() copy, toArray(), docblock array shape, TypeScript interface, dan key list di NavigationTest. |
Key baru pada Page::metadata() | Strict assertion ->has('page', ...) pada PanelShellTest. |
| Lifecycle hook baru | Urutan di HasLifecycleHooks, fixture HookedCreateUser / HookedEditUser, dan ResourceLifecycleHookTest. |
| Icon name baru di PHP | php artisan panel:icons, yang menulis ulang resources/js/panel/icons/registry.ts. Jangan edit file tersebut manual. |
| Panel asset entrypoint baru | input pada vite.config.ts, atau Page gagal dengan manifest error. |
| Config file baru di repository root | Tambahkan export-ignore di .gitattributes. |
| CSS hook name baru | hook('name') pada component yang merendernya — StylingTest memastikan setiap allowlisted name benar-benar digunakan. |
Catatan
declare(strict_types=1)wajib. Pint menambahkannya dan CI--testakan gagal jika file tidak memilikinya.- Jangan menambahkan PHPStan baseline. Saat ini analyser bersih. Baseline pada dasarnya adalah daftar issue yang disepakati untuk tidak diperbaiki.
- Jangan memanggil
Gate::allows()langsung. Authorization harus melaluiPandaPanel\Support\PolicyGate::allows()karena strict-authorization behavior hidup di sana. - Jangan memanggil
DB::transaction()langsung di Page atau Action. GunakanPandaPanel\Support\DatabaseTransaction::run(?bool, Closure)yang me-resolve keputusan dengan urutan action → page → panel → default on.nullberarti "belum memutuskan", bukan "off". import.meta.globtidak boleh menerima alias@. Dev server Vite dapat me-resolve aliased pattern menjadi kosong sementara production build bekerja, sehingga bug hanya muncul saat development. Gunakan relative pattern.- Generated code juga wajib lolos standard.
GeneratorTestmenjalankan Pint terhadap outputmake:panel-resource, sehingga stub yang drift akan membuat test suite gagal.
Lihat juga
- Local development — cara menjalankan semua tool beserta flag-nya
- Running the tests — bagian ketiga dari
composer ci - Frontend toolchain — config di balik frontend checks
- Architecture decisions — convention yang berasal dari keputusan arsitektur
- Pull requests — checklist review
- CI matrix — tempat setiap check dijalankan
- Component registries — mengapa component name di-resolve saat build
- Server metadata to Vue — detail serialization boundary