Cách migrate và restore dữ liệu Laravel sang Ghost CMS: bài viết, media và SEO
Muốn chuyển website Laravel sang Ghost CMS mà không mất bài viết, ảnh, slug và tín hiệu SEO? Cách an toàn là giữ nguyên database Laravel, đọc dữ liệu offline, ánh xạ sang schema Ghost rồi ghi qua Ghost Admin API. Tuyệt đối không import trực tiếp file SQL Laravel vào database Ghost.
Ở Phần 1: Cách cài Ghost CMS production trên shared hosting cPanel có Node.js, tôi đã trình bày cách cài Ghost production trên shared hosting cPanel với Node.js, Passenger và MariaDB. Bài này tập trung vào giai đoạn tiếp theo: migrate và restore dữ liệu blog Laravel sang Ghost CMS mà vẫn giữ permalink, media và khả năng kiểm soát SEO.
Quy trình được viết từ case study TechNT đã thực hiện trên website thật. Dữ liệu đầu vào gồm 44 bài viết, 2 trang tĩnh, 16 category và 92 file media WebP. Sau migration, toàn bộ slug nguồn được đối chiếu, media được upload vào Ghost, các URL root-level trả HTTP 200 và URL không tồn tại trả 404 native.
1. Vì sao không được import SQL Laravel trực tiếp vào Ghost?
Laravel và Ghost có schema hoàn toàn khác nhau. Database Laravel thường có các bảng như posts, categories, media, users và bảng quan hệ tùy theo source code. Ghost lại có hệ thống bảng riêng cho posts, pages, tags, authors, members, settings, migrations và nhiều quan hệ nội bộ khác.
Nếu đưa nguyên file SQL Laravel vào database Ghost, bạn có thể làm hỏng migration của Ghost, gây xung đột tên bảng, sai constraint hoặc khiến Ghost không khởi động. Database dump Laravel chỉ nên được dùng làm nguồn đọc offline, không phải file để restore trực tiếp vào database Ghost.
Kiến trúc an toàn là:
Laravel SQL + storage backup
│
▼
Parser chạy offline
│
├── Bài viết / trang ──► Ghost Admin API
├── Category ─────────► Ghost tags
└── Media ─────────────► Ghost image upload
Ghost database chỉ được ghi bởi Ghost Admin API hoặc các công cụ import chính thức tương thích với Ghost. Cách này giúp Ghost tự tạo đúng ID, quan hệ, metadata và dữ liệu nội bộ.
2. Chuẩn bị backup và inventory dữ liệu
Trước khi migration, hãy tạo ít nhất hai bản backup độc lập:
technt-laravel.sql
storage.zip
Không upload password database, API key hoặc file backup chứa dữ liệu riêng tư vào Git public. Bản backup gốc cần được giữ nguyên cho đến khi website Ghost đã được kiểm tra trong giai đoạn hậu migration.
Sau đó lập inventory offline. Không bắt đầu bằng việc gửi toàn bộ dump lên Ghost; trước hết cần biết dữ liệu nguồn có bao nhiêu record, slug trùng nào, category nào thiếu và media nằm ở đâu.
| Entity Laravel | Đích Ghost | Ghi chú |
|---|---|---|
posts.title |
posts.title |
Giữ nguyên tiêu đề nếu không có yêu cầu biên tập |
posts.slug |
posts.slug |
Dùng slug làm khóa đối chiếu idempotent |
posts.content |
Body content | Chuyển HTML sang nội dung native Ghost |
posts.status |
published hoặc draft |
Giữ trạng thái nguồn |
posts.is_page |
type: page |
Tách page khỏi post |
| Categories | Ghost tags | Ghost không dùng category theo schema Laravel |
| Thumbnail | feature_image |
Upload lại vào Ghost Content API path |
| Inline media | URL Ghost | Rewrite link cũ trong HTML |
| SEO title | meta_title |
Giới hạn theo giao diện/SEO thực tế |
| SEO description | meta_description |
Cắt theo giới hạn phù hợp |
Trong case study TechNT, inventory cho kết quả 44 bài viết, 2 trang tĩnh, 16 category và 92 file WebP. Số liệu này được dùng làm expected count để kiểm tra sau migration.
3. Cấu hình permalink trước khi import
Nếu Laravel đang dùng URL root-level như:
https://example.com/slug-bai-viet/
hãy cấu hình Ghost dùng cùng một chuẩn trước khi tạo hoặc publish nội dung. Trong Settings → Labs → Routes, upload routes.yaml:
routes:
collections:
/:
permalink: /{slug}/
template: index
taxonomies:
tag: /category/{slug}/
author: /author/{slug}/
Ghost hỗ trợ upload routes.yaml từ Admin; nếu chỉnh file thủ công trong content/settings/routes.yaml, thường cần restart Ghost 1. Chỉ chọn một chuẩn trailing slash và sử dụng nhất quán.
Kiểm tra route trước khi import số lượng lớn:
curl -I https://example.com/
curl -I https://example.com/url-khong-ton-tai/
URL không tồn tại phải trả 404 native. Không redirect mọi URL lỗi về homepage, vì cách đó tạo soft-404 và có thể tiếp tục làm Google index URL rác.
4. Tạo Custom Integration và bảo vệ API key
Trong Ghost Admin, vào Settings → Integrations → Add custom integration. Đặt tên integration theo mục đích, ví dụ Laravel Migration.
Admin API key có dạng:
KEY_ID:SECRET_HEX
Không gửi key qua chat, không commit vào Git và không đặt cố định trong script. Nên truyền key qua biến môi trường trong phiên Terminal riêng:
export GHOST_ADMIN_KEY='KEY_ID:SECRET_HEX'
export GHOST_API_URL='https://example.com'
Script phải tạo JWT ngắn hạn từ key này. JWT cần được tạo lại trước các request vì token có thời hạn ngắn. Sau khi migration và validation hoàn tất, xóa Custom Integration tạm thời để thu hồi API key.
5. Upload media trước khi upsert nội dung
Media là phần dễ làm migration không hoàn chỉnh nhất. Nếu body đã được import nhưng ảnh vẫn trỏ về Laravel cũ, bài có thể hiển thị lỗi hoặc phát sinh hàng loạt request 404.
Quy trình nên là:
- Đọc danh sách file trong
storage.zip. - Xác định file nào là thumbnail, file nào là inline media.
- Upload từng ảnh vào Ghost bằng endpoint media phù hợp.
- Lưu bản đồ old URL → new Ghost URL vào file checkpoint.
- Rewrite HTML của bài viết theo bản đồ mới.
- Chỉ sau đó mới cập nhật body bài viết.
Ví dụ bản đồ media:
{
"/storage/posts/example.webp": "/content/images/2026/08/example.webp"
}
Không nên tạo một request cực lớn chứa toàn bộ ảnh và bài viết cùng lúc trên shared hosting. Hãy upload theo từng file hoặc từng nhóm nhỏ, ghi checkpoint sau mỗi lần thành công và có thể chạy lại theo đường dẫn nguồn mà không tạo duplicate.
Trong case study, 92 file WebP đã được upload và mapping sang URL Ghost. Sau migration, một số URL media được kiểm tra với HTTP 200 và Content-Type: image/webp.
6. Upsert tags và quan hệ category
Ghost dùng tags thay cho category theo schema nguồn Laravel. Với mỗi category, chuẩn hóa tên và slug trước khi tạo tag. Không tạo tag trùng nếu script được chạy lại.
Nên dùng cache trong bộ nhớ hoặc file mapping:
{
"seo": "6a82...",
"laravel": "6a82...",
"docker": "6a82..."
}
Sau đó ánh xạ quan hệ bài viết–category sang bài viết–tag. Một bài có nhiều category cần giữ đủ quan hệ, không chỉ giữ category đầu tiên.
| Dữ liệu nguồn | Dữ liệu Ghost |
|---|---|
| Category name | Tag name |
| Category slug | Tag slug |
| Post–category pivot | Post–tag relation |
| Category image | Tag image nếu theme cần |
Trong case study, 16 category được chuyển thành 16 Ghost tags và 70 quan hệ bài–category được áp dụng qua post tags.
7. Chuyển nội dung HTML sang body native của Ghost
Đây là điểm cần đặc biệt lưu ý. Lần import đầu có thể tạo được title, slug và status nhưng Editor vẫn trống nếu request chỉ gửi field html theo cách Ghost không kích hoạt bộ chuyển đổi nội dung.
Dấu hiệu của lỗi này là:
| Kiểm tra | Trạng thái lỗi |
|---|---|
| Bài xuất hiện trong danh sách Admin | Có |
| Title và slug | Có |
| Status | Có |
| Ghost Editor | 0 words hoặc trống |
| Body API | HTML rỗng hoặc chưa được chuyển native |
Cách khôi phục an toàn là cập nhật bài bằng Admin API với query parameter source=html, đồng thời giữ lại id và updated_at để tránh ghi đè thay đổi mới hơn:
PUT /ghost/api/admin/posts/{id}/?source=html
Content-Type: application/json
Authorization: Ghost <JWT>
{
"posts": [
{
"id": "GHOST_POST_ID",
"updated_at": "CURRENT_UPDATED_AT",
"html": "<h2>Tiêu đề</h2><p>Nội dung bài viết...</p>"
}
]
}
Khi dùng source=html, Ghost chuyển HTML thành nội dung native mà Editor có thể hiển thị và chỉnh sửa. Không nên đặt nguyên chuỗi HTML vào một HTML card nếu mục tiêu là có một bài viết native dễ quản trị.
Một script production nên xác minh sau từng request:
returned_html_length > 0
lexical content tồn tại
không có wrapper HTML card ngoài ý muốn
status và slug không thay đổi
8. Giữ slug và status bằng cơ chế idempotent
Slug là khóa quan trọng nhất khi migrate blog. Không nên đối chiếu bài bằng title vì title có thể đổi dấu câu, viết hoa hoặc trùng nhau. Hãy chuẩn hóa slug và tìm bài Ghost theo slug.
Quy trình idempotent:
Nếu slug chưa tồn tại → tạo bài mới
Nếu slug đã tồn tại → cập nhật đúng ID
Nếu request thất bại → ghi lỗi và chạy lại slug đó
Nếu đã thành công → không tạo duplicate
Timestamp Laravel cần được chuyển sang ISO 8601 UTC trước khi gửi sang Ghost. custom_excerpt cũng cần giới hạn theo schema Ghost; không gửi chuỗi vượt giới hạn chỉ vì excerpt nguồn dài hơn.
Khi cập nhật bài đã có, cần giữ lại các trường không liên quan như tags, feature image, meta title, meta description và status. Nếu payload chỉ chứa body nhưng API yêu cầu metadata hiện hữu, hãy GET bản ghi trước rồi merge có kiểm soát.
9. Ví dụ cấu trúc pipeline migration
Một pipeline có thể chia thành các bước độc lập:
01-inspect-source
├── parse SQL
├── validate slug
└── build expected counts
02-upload-media
├── upload WebP
└── write media-map.json
03-upsert-tags
├── create/find tags
└── write tag-map.json
04-upsert-posts
├── match by slug
├── rewrite media URLs
├── PUT with source=html
└── verify body
05-verify
├── count posts/pages/tags
├── check missing slug
├── check duplicates
├── check HTTP 200
└── check canonical and 404
Mỗi bước nên ghi JSON report. Không dùng một script không có checkpoint cho toàn bộ database, vì một request timeout giữa chừng sẽ khiến bạn không biết record nào đã thành công.
10. Kiểm tra sau migration
Không nên chỉ nhìn thấy bài xuất hiện trong Ghost Admin rồi kết luận migration thành công. Hãy kiểm tra theo cả ba lớp: database/API, Editor và frontend public.
Kiểm tra số lượng
Đối chiếu expected count với kết quả thực tế:
| Entity | Expected trong case study | Điều kiện đạt |
|---|---|---|
| Bài viết | 44 | Đủ 44 slug |
| Trang tĩnh | 2 | Đủ 2 slug |
| Tags từ category | 16 | Đủ tag và slug |
| Media | 92 | Đủ URL upload thành công |
| Quan hệ bài–tag | 70 | Không mất quan hệ |
Kiểm tra nội dung trong Editor
Mở ngẫu nhiên một bài ngắn, một bài có bảng/code và một bài có nhiều ảnh. Editor phải hiển thị số words khác 0, heading đúng cấp, code block đọc được và ảnh có URL Ghost.
Kiểm tra frontend
Dùng curl hoặc trình duyệt kiểm tra một số URL:
curl -I https://example.com/robots-txt-la-gi-cach-su-dung-toi-uu-seo/
curl -I https://example.com/cloud-hosting-vs-shared-hosting/
curl -I https://example.com/url-khong-ton-tai/
URL hợp lệ cần trả HTTP 200, không redirect vòng hoặc chuyển về homepage. URL không tồn tại cần trả 404 native.
Kiểm tra canonical và media
Mỗi bài cần có canonical trỏ đến URL chính dạng HTTPS, đúng domain và đúng slug. Một số ảnh inline cần trả HTTP 200, không còn URL Laravel cũ và không bị mixed content.
11. SEO sau khi chuyển từ Laravel sang Ghost
Migration kỹ thuật và migration SEO là hai việc khác nhau. Sau khi body đã có nội dung, hãy kiểm tra:
| Hạng mục | Điều kiện đạt |
|---|---|
| Slug | Giữ nguyên slug có giá trị SEO |
| URL | Một chuẩn root-level và trailing slash |
| Canonical | Trỏ về URL Ghost chính |
| Meta title | Có title phù hợp cho bài quan trọng |
| Meta description | Mô tả đúng nội dung, không lặp máy móc |
| Sitemap | Chỉ chứa URL hợp lệ |
| Media | Không còn link Laravel cũ |
| Redirect | URL thật sự đổi mới dùng 301 có mục tiêu |
| URL rác | Trả 404 native |
| Search Console | Gửi sitemap và request indexing có chọn lọc |
Không redirect mọi URL cũ về homepage. Nếu URL cũ có nội dung tương đương, tạo redirect 301 cụ thể; nếu không có thay thế phù hợp, giữ HTTP 404 để Google loại URL khỏi chỉ mục theo thời gian.
Với website từng có nhiều URL index hỗn độn, hãy xử lý theo thứ tự: sửa routes, bảo đảm canonical, loại soft-404, kiểm tra sitemap, sau đó mới request indexing các URL quan trọng. Không nên gửi hàng loạt URL khi frontend vẫn còn trả nội dung sai.
12. Những lỗi thường gặp
Bài có title nhưng Editor trống
Kiểm tra body API và số words trong Editor. Nếu HTML nguồn vẫn còn trong bundle, cập nhật lại bài bằng source=html. Đừng xóa bài và tạo lại nếu slug, tags, feature image hoặc lịch sử URL đã được giữ đúng.
Script báo mọi bài đều missing
Kiểm tra response list endpoint trước khi kết luận dữ liệu mất. Ghost Admin API có thể trả field type khác với giả định của script. Cách đối chiếu đáng tin cậy hơn là dùng slug và xác minh từng ID.
Media trả 404
Kiểm tra media map, quyền thư mục content/images, URL HTTPS và phần rewrite trong HTML. Không sửa URL bằng replace mù nếu domain cũ xuất hiện trong code block hoặc văn bản hướng dẫn.
Bài bị duplicate sau khi chạy lại
Script chưa idempotent hoặc đang match theo title thay vì slug. Hãy GET theo slug trước khi POST tạo mới, lưu mapping source ID → Ghost ID và dùng updated_at khi PUT.
Ghost trả lỗi do payload quá lớn
Chia batch nhỏ hơn, upload media trước và ghi checkpoint. Shared hosting có thể đóng kết nối dù server vẫn đang xử lý; không nên gửi toàn bộ 44 bài cùng một request.
Câu hỏi thường gặp
Có thể restore SQL Laravel trực tiếp vào MariaDB của Ghost không?
Không. Database Laravel và Ghost có schema khác nhau. SQL Laravel chỉ dùng làm nguồn đọc offline; dữ liệu phải được mapping và ghi qua Ghost Admin API.
Có giữ nguyên slug Laravel được không?
Có. Hãy cấu hình routes.yaml root-level trước, dùng slug làm khóa đối chiếu và kiểm tra canonical sau khi import. Nếu một slug buộc phải thay đổi, hãy tạo redirect 301 riêng cho URL đó.
Có cần import cả bảng users Laravel không?
Không nhất thiết. Ghost có hệ thống staff và members riêng. Chỉ migrate dữ liệu người dùng khi có yêu cầu nghiệp vụ và đã thiết kế mapping quyền, consent, password reset phù hợp.
Vì sao HTML trong bundle còn nguyên nhưng Ghost Editor lại trống?
HTML nguồn có thể đúng nhưng request import chưa dùng cơ chế chuyển đổi phù hợp của Ghost. Cập nhật bằng source=html giúp Ghost chuyển body thành nội dung native mà Editor hiểu được.
Khi nào được xóa backup Laravel?
Chỉ xóa sau khi có bản backup độc lập, đã kiểm tra đủ bài, page, tags, media, canonical, sitemap, redirect và các URL quan trọng trong giai đoạn theo dõi.
Kết luận
Migrate Laravel sang Ghost không phải là việc copy một file SQL sang database mới. Đây là một pipeline gồm inventory, mapping schema, upload media, upsert tags, chuyển HTML sang native content, giữ slug và kiểm tra SEO sau migration.
Case study TechNT cho thấy quy trình an toàn có thể khôi phục 44 bài viết, 2 trang tĩnh, 16 category thành tags và 92 media WebP mà không import trực tiếp SQL Laravel vào Ghost. Điểm quan trọng nhất là dùng slug làm khóa idempotent, bảo toàn backup và xác minh body bằng cả Ghost Editor lẫn URL public.
Nếu bạn chưa cài Ghost, hãy bắt đầu với Phần 1: Cách cài Ghost CMS production trên shared hosting cPanel có Node.js, sau đó quay lại bài này để thực hiện migration từng bước.