php artisan posts:backup Backup all posts with media to the default location (backups/posts/posts_YYYYMMDD_HHMMSS.zip) php artisan posts:backup --output=my_backup.zip Backup all posts with media to a custom file path php artisan posts:backup --post-ids=29,30,405,502 Backup only specific posts by their IDs php artisan posts:backup --exclude-media Backup posts without media files (JSON-only, smaller file size) php artisan posts:backup --post-ids=29,30 --output=backups/selected_posts.zip Backup specific posts to a custom file path HELP; /** * Execute the console command. */ public function handle(): int { $this->info('Starting posts backup...'); $excludeMedia = $this->option('exclude-media'); // Determine output path $outputPath = $this->option('output'); $extension = $excludeMedia ? 'json' : 'zip'; if (!$outputPath) { $backupDir = base_path('backups/posts'); if (!File::isDirectory($backupDir)) { File::makeDirectory($backupDir, 0755, true); } $timestamp = now()->format('Ymd_His'); $outputPath = "{$backupDir}/posts_backup_{$timestamp}.{$extension}"; } // Ensure directory exists $directory = dirname($outputPath); if (!File::isDirectory($directory)) { File::makeDirectory($directory, 0755, true); } // Build posts query (only non-deleted posts) $query = Post::query(); // Filter by specific post IDs if provided $postIdsOption = $this->option('post-ids'); if ($postIdsOption) { $postIds = array_map('trim', explode(',', $postIdsOption)); $postIds = array_filter($postIds, fn($id) => is_numeric($id)); if (empty($postIds)) { $this->error('Invalid post IDs provided. Use comma-separated numeric IDs (e.g., --post-ids=29,30,405)'); return Command::FAILURE; } $query->whereIn('id', $postIds); $this->info('Filtering by post IDs: ' . implode(', ', $postIds)); } $totalPosts = $query->count(); if ($totalPosts === 0) { $this->warn('No posts found to backup.'); return Command::SUCCESS; } $this->info("Found {$totalPosts} posts to backup"); // Build category type lookup (id => type) $categoryTypes = Category::pluck('type', 'id')->toArray(); // Track counts and media files $counts = ['posts' => 0, 'post_translations' => 0, 'meetings' => 0, 'media_files' => 0]; $mediaFiles = []; // Write posts as JSON incrementally to a temp file to avoid holding everything in memory $tempJsonPath = storage_path('app/temp/' . uniqid('backup_json_') . '.json'); $tempDir = dirname($tempJsonPath); if (!File::isDirectory($tempDir)) { File::makeDirectory($tempDir, 0755, true); } $jsonHandle = fopen($tempJsonPath, 'w'); fwrite($jsonHandle, '{"meta":"__PLACEHOLDER__","posts":['); $bar = $this->output->createProgressBar($totalPosts); $bar->start(); $isFirst = true; $query->with(['translations', 'meeting.location', 'media']) ->chunk(100, function ($posts) use ( $categoryTypes, $excludeMedia, $jsonHandle, &$isFirst, &$counts, &$mediaFiles, $bar ) { foreach ($posts as $post) { $categoryType = $categoryTypes[$post->category_id] ?? null; $postData = [ 'category_type' => $categoryType, 'love_reactant_id' => $post->love_reactant_id, 'author_id' => $post->author_id, 'author_model' => $post->author_model, 'created_at' => $this->formatDate($post->created_at), 'updated_at' => $this->formatDate($post->updated_at), 'translations' => [], 'meeting' => null, 'media' => null, ]; foreach ($post->translations as $translation) { $postData['translations'][] = [ 'locale' => $translation->locale, 'slug' => $translation->slug, 'title' => $translation->title, 'excerpt' => $translation->excerpt, 'content' => $translation->content, 'status' => $translation->status, 'updated_by_user_id' => $translation->updated_by_user_id, 'from' => $this->formatDate($translation->from), 'till' => $this->formatDate($translation->till), 'created_at' => $this->formatDate($translation->created_at), 'updated_at' => $this->formatDate($translation->updated_at), ]; $counts['post_translations']++; } if ($post->meeting) { $meeting = $post->meeting; $postData['meeting'] = [ 'meetingable_type' => $meeting->meetingable_type, 'meetingable_name' => $meeting->meetingable?->name, 'venue' => $meeting->venue, 'address' => $meeting->address, 'price' => $meeting->price, 'based_on_quantity' => $meeting->based_on_quantity, 'transaction_type_id' => $meeting->transaction_type_id, 'status' => $meeting->status, 'from' => $this->formatDate($meeting->from), 'till' => $this->formatDate($meeting->till), 'created_at' => $this->formatDate($meeting->created_at), 'updated_at' => $this->formatDate($meeting->updated_at), 'location' => $this->getLocationNames($meeting->location), ]; $counts['meetings']++; } if (!$excludeMedia) { $media = $post->getFirstMedia('posts'); if ($media) { $originalPath = $media->getPath(); if (File::exists($originalPath)) { $archivePath = "media/{$post->id}/{$media->file_name}"; $postData['media'] = [ 'name' => $media->name, 'file_name' => $media->file_name, 'mime_type' => $media->mime_type, 'size' => $media->size, 'collection_name' => $media->collection_name, 'custom_properties' => $media->custom_properties, 'archive_path' => $archivePath, ]; $mediaFiles[] = [ 'source' => $originalPath, 'archive_path' => $archivePath, ]; $counts['media_files']++; } } } if (!$isFirst) { fwrite($jsonHandle, ','); } fwrite($jsonHandle, json_encode($postData, JSON_UNESCAPED_UNICODE)); $isFirst = false; $counts['posts']++; $bar->advance(); } }); $bar->finish(); $this->newLine(); // Close JSON array fwrite($jsonHandle, ']}'); fclose($jsonHandle); // Build meta JSON $meta = json_encode([ 'version' => '2.0', 'created_at' => now()->toIso8601String(), 'source_database' => config('database.connections.mysql.database'), 'includes_media' => !$excludeMedia, 'counts' => $counts, ], JSON_UNESCAPED_UNICODE); // Replace the placeholder in the temp JSON file without reading it all into memory $finalJsonPath = $tempJsonPath . '.final'; $inHandle = fopen($tempJsonPath, 'r'); $outHandle = fopen($finalJsonPath, 'w'); // Read the placeholder prefix, replace it, then stream the rest $prefix = fread($inHandle, strlen('{"meta":"__PLACEHOLDER__"')); fwrite($outHandle, '{"meta":' . $meta); // Stream the rest of the file in small chunks while (!feof($inHandle)) { fwrite($outHandle, fread($inHandle, 8192)); } fclose($inHandle); fclose($outHandle); @unlink($tempJsonPath); rename($finalJsonPath, $tempJsonPath); if ($excludeMedia) { // Move temp JSON as the final output File::move($tempJsonPath, $outputPath); } else { // Create ZIP archive with JSON and media files $this->info('Creating ZIP archive with media files...'); if (!class_exists('ZipArchive')) { @unlink($tempJsonPath); $this->error('ZipArchive extension is not available. Install php-zip extension or use --exclude-media flag.'); return Command::FAILURE; } $zip = new ZipArchive(); if ($zip->open($outputPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { @unlink($tempJsonPath); $this->error("Failed to create ZIP archive: {$outputPath}"); return Command::FAILURE; } // Add backup.json to archive $zip->addFile($tempJsonPath, 'backup.json'); // Add media files to archive $mediaBar = $this->output->createProgressBar(count($mediaFiles)); $mediaBar->start(); foreach ($mediaFiles as $mediaFile) { if (File::exists($mediaFile['source'])) { $zip->addFile($mediaFile['source'], $mediaFile['archive_path']); } $mediaBar->advance(); } $mediaBar->finish(); $this->newLine(); $zip->close(); @unlink($tempJsonPath); } $fileSize = $this->formatBytes(File::size($outputPath)); $this->info("Backup completed successfully!"); $this->table( ['Metric', 'Value'], [ ['Posts', $counts['posts']], ['Translations', $counts['post_translations']], ['Meetings', $counts['meetings']], ['Media Files', $counts['media_files']], ['File Size', $fileSize], ['Output File', $outputPath], ] ); return Command::SUCCESS; } /** * Format bytes to human readable format. */ private function formatBytes(int $bytes): string { $units = ['B', 'KB', 'MB', 'GB']; $i = 0; while ($bytes >= 1024 && $i < count($units) - 1) { $bytes /= 1024; $i++; } return round($bytes, 2) . ' ' . $units[$i]; } /** * Safely format a date value to ISO8601 string. */ private function formatDate($value): ?string { if ($value === null) { return null; } if ($value instanceof \Carbon\Carbon || $value instanceof \DateTime) { return $value->format('c'); } if (is_string($value)) { return $value; } return null; } /** * Get location names in the app's base locale for backup. * Names are used for lookup on restore instead of IDs. */ private function getLocationNames($location): ?array { if (!$location) { return null; } $baseLocale = config('app.locale'); // Get country name $countryName = null; if ($location->country_id) { $countryLocale = CountryLocale::withoutGlobalScopes() ->where('country_id', $location->country_id) ->where('locale', $baseLocale) ->first(); $countryName = $countryLocale?->name; } // Get division name $divisionName = null; if ($location->division_id) { $divisionLocale = DivisionLocale::withoutGlobalScopes() ->where('division_id', $location->division_id) ->where('locale', $baseLocale) ->first(); $divisionName = $divisionLocale?->name; } // Get city name $cityName = null; if ($location->city_id) { $cityLocale = CityLocale::withoutGlobalScopes() ->where('city_id', $location->city_id) ->where('locale', $baseLocale) ->first(); $cityName = $cityLocale?->name; } // Get district name $districtName = null; if ($location->district_id) { $districtLocale = DistrictLocale::withoutGlobalScopes() ->where('district_id', $location->district_id) ->where('locale', $baseLocale) ->first(); $districtName = $districtLocale?->name; } return [ 'country_name' => $countryName, 'division_name' => $divisionName, 'city_name' => $cityName, 'district_name' => $districtName, ]; } }