번들 확장 20개 전부에 AGENTS.md · README.md · docs/ 를 채우고, 코어가 들고 있던 확장 소유 문서 두 갈래를 그 확장으로 옮긴다. 번들 템플릿의 컴포넌트· 핸들러·레이아웃 상세와 확장이 구독하는 활동 로그 훅 목록이 그 대상이며, 코어에는 총계와 링크만 남아 확장이 기능을 늘릴 때 코어 문서를 고쳐야 하던 역방향 의존이 사라진다. 전수 완비를 확인하고 강제를 조인다 — 문서 동반 룰을 대상 목록 없는 error 로 승격하고, 검사 스크립트가 문서 미보유를 실패로 올리며, 미채움 마커 baseline 을 0 으로 기록한다. 한쪽만 조이면 "새 확장이 문서 없이 들어와도 초록" 인 상태가 남는데 그 결과는 이상 0건과 구분되지 않는다. 집필 과정에서 드러난 생성기 결함 셋을 함께 고친다. 스케줄 주기 열이 계약 키를 읽지 않아 모든 확장에서 '-' 였고, 네임스페이스를 붙인 핸들러 등록 키가 수집에서 통째로 빠졌으며, README 골격이 폐기된 히어로 배지를 계속 찍어내고 있었다. 셋 다 산출물이 아니라 원천이 틀린 것이라, 가드의 모집단에 생성기 출력 자체를 넣어 다음 확장이 같은 상태로 태어나는 경로를 막는다.
8.0 KiB
페이지 — 데이터 모델
모델·소유 테이블·마이그레이션·Enum · 진입점: AGENTS.md
모델
| 모델 | 테이블 | fillable | 관계 | 특성 |
|---|---|---|---|---|
Page |
pages |
11 | creator→User, updater→User, versions→PageVersion, attachments→PageAttachment | 검색 색인 |
PageAttachment |
page_attachments |
13 | page→Page, creator→User | - |
PageVersion |
page_versions |
8 | page→Page, creator→User | - |
세 모델의 관계는 Page ─1:N─ PageVersion / PageAttachment 하나뿐입니다.
Page—slug가 사실상의 주소이고current_version이 이력의 현재 위치입니다.title과content는AsUnicodeJson캐스팅이라 다국어 값을 담습니다(로케일별 문자열 맵).content_thumbnail_url은 본문에서 뽑은 대표 이미지를 저장해 둔 것이라, 본문을 고치면 함께 갱신되어야 합니다.PageVersion— 저장 시점의title/content/content_mode/seo_meta스냅샷입니다. 복원은 이 값을 현재 페이지에 쓰고current_version을 또 1 올린 뒤 스냅샷을 한 번 더 남깁니다.PageAttachment— 공개 서빙은 순번 ID 가 아니라 해시로 합니다. 부모 페이지의 발행 상태가 곧 첨부의 공개 여부이며, 내려받기와 미리보기 두 경로가 각자 그 판정을 합니다.
셋 다 SoftDeletes 를 쓰지 않습니다. Page 의 소프트 삭제는 마이그레이션
2026_06_29_000001 이, PageAttachment 는 2026_06_29_000002 가 걷어냈습니다. 삭제된
페이지가 보이지 않게 남아 있으면 같은 slug 를 다시 쓸 수 없기 때문이며, 되돌리기의 책임은
버전 이력이 집니다.
Page 만 검색 색인 대상입니다. 색인에 실리는 컬럼과 가중치는 모델의 searchableColumns() ·
searchableWeights() 가 선언하고, 다시 태울지 여부는 searchIndexShouldBeUpdated() 와
search.page.index_should_update 필터가 정합니다.
소유 테이블
| 테이블 | 모델 |
|---|---|
page_attachments |
PageAttachment |
page_versions |
PageVersion |
pages |
Page |
세 테이블 모두 모델과 1:1 이며 피벗이 없습니다. 접두사가 page_ 로 짧은 것은 이 모듈이
코어에 가까운 기본 기능이라는 초기 판단 때문이며, 다른 확장이 같은 이름을 쓰지 않도록
주의합니다.
pages 에는 FULLTEXT 인덱스(2026_04_01_000004)와 발행 정렬 인덱스(2026_08_02_000001)가
따로 붙어 있습니다. 목록·검색 쿼리를 새로 만들 때 이 두 인덱스를 쓰는 형태인지 확인합니다 —
컬럼에 함수를 씌우거나(whereDate 등) 정렬 컬럼을 바꾸면 인덱스가 쓰이지 않습니다.
삭제는 DB CASCADE 에 맡기지 않습니다. 페이지를 지울 때 PageService 가 첨부를 하나씩
PageAttachmentService::deleteAttachment() 로 지웁니다 — 물리 파일 삭제와 훅 발행이 함께
일어나야 하는데, CASCADE 로 지우면 그 둘이 통째로 건너뛰어지고 아무 오류도 남지 않습니다.
마이그레이션
마이그레이션 8개.
| 파일 | 생성 테이블 | 변경 테이블 | down() |
|---|---|---|---|
2026_04_01_000001_create_pages_table.php |
pages |
pages |
✅ |
2026_04_01_000002_create_page_versions_table.php |
page_versions |
page_versions |
✅ |
2026_04_01_000003_create_page_attachments_table.php |
page_attachments |
page_attachments |
✅ |
2026_04_01_000004_add_fulltext_indexes_to_pages_table.php |
- | pages |
✅ |
2026_06_29_000001_drop_soft_deletes_from_pages_table.php |
- | pages |
✅ |
2026_06_29_000002_drop_soft_deletes_from_page_attachments_table.php |
- | page_attachments |
✅ |
2026_08_02_000001_add_published_sort_index_to_pages_table.php |
- | - | ✅ |
2026_08_22_000001_add_content_thumbnail_url_to_pages_table.php |
- | pages |
✅ |
8개 중 3개가 초기 스키마이고 5개는 이후의 변경입니다. 그 5개가 이 모듈이 겪은 설계 변경을 그대로 보여줍니다:
| 마이그레이션 | 무엇이 바뀌었나 |
|---|---|
add_fulltext_indexes_to_pages_table |
통합 검색 편입을 위해 FULLTEXT 인덱스 추가 |
drop_soft_deletes_from_pages_table · ..._page_attachments_table |
소프트 삭제 철회 — slug 재사용을 막기 때문 |
add_published_sort_index_to_pages_table |
발행 목록 정렬의 인덱스 확보 |
add_content_thumbnail_url_to_pages_table |
본문 대표 이미지를 조회 때마다 뽑지 않고 저장 |
새 컬럼을 더할 때 초기 create_* 파일을 고치지 않습니다 — 이미 설치된 사이트는 그 파일을
다시 실행하지 않으므로 반영되지 않습니다. 컬럼 기본값·comment·데이터 형태를 바로잡는 변경은
마이그레이션과 함께 upgrades/ 의 업그레이드 스텝 백필이 필요합니다.
한국어 comment 와 down() 은 필수이고, FK 컬럼의 ->comment() 는 ->constrained() 앞에
둡니다(뒤에 두면 comment 가 FK 정의에 붙어 조용히 사라집니다).
Enum
Enum 이 없습니다.
없습니다. 이 도메인의 상태는 published 불리언 하나뿐이라 분류 어휘가 생기지 않았습니다.
content_mode 는 문자열 컬럼입니다 — 편집기(위지윅/평문)가 무엇을 저장했는지를 나타내며,
편집기 확보에 실패했을 때의 폴백 계약(text)과 짝을 이룹니다. 값의 가짓수가 늘어나
분기가 생기기 시작하면 그때 Enum 으로 올리는 것이 맞습니다.
Repository
| 클래스 | 종류 | 설명 |
|---|---|---|
PageAttachmentRepository |
구현 | 페이지 첨부파일 Repository |
PageAttachmentRepositoryInterface |
인터페이스 | 페이지 첨부파일 Repository 인터페이스 |
PageRepository |
구현 | 페이지 Repository |
PageRepositoryInterface |
인터페이스 | 페이지 Repository 인터페이스 |
PageVersionRepository |
구현 | 페이지 버전 Repository |
PageVersionRepositoryInterface |
인터페이스 | 페이지 버전 Repository 인터페이스 |
세 Repository 모두 인터페이스와 1:1 이며 서비스는 인터페이스만 주입받습니다(구체 클래스 타입힌트 금지).
이 모듈에서 특히 걸리는 것 둘:
- 버전 조회는 반드시 페이지 스코프로.
PageVersionRepository::findForPage($pageId, $versionId)처럼 상위 리소스 ID 를 where 절에 반영합니다. 버전 ID 만으로 찾으면 다른 페이지의 버전을 현재 페이지에 복원할 수 있는 교차 접근 경로가 생기는데, 정상 응답이 나가므로 오류도 로그도 남지 않습니다. - 목록 쿼리의 컬럼 프루닝과 정렬 화이트리스트. 페이지 본문은 큰 컬럼이라 목록에 실으면
오버플로 페이지 읽기가 발생합니다. 정렬은
PageService::SEARCH_SORT_MAP이 닫힌 집합을 정하며, 화면 정렬 옵션 ⊆ 검증 게이트 ⊆ 이 선언 순서로 포함 관계가 유지되어야 합니다.