This GIST shows extracts of the code that allows us to push people data from our internal "Cranleigh People" Laravel app, as a POST request with a JSON body, to our WordPress site. WordPress then ingests it, and either updates and creates Custom Post Types based on the data it is sent.
Last active
January 13, 2026 14:16
-
-
Save fredbradley/26127a28abd3b1cee79030394e2b038f to your computer and use it in GitHub Desktop.
Extracts from the way our People Manager pushes data to the WordPress website.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * On the WordPress side, this is the controller that ingests the JSON body, maps it to how WordPress wants it, | |
| * then for each `$person` runs the `ImportPerson` class. Which adds the data from the JSON into a | |
| * WordPress Custom Post type with a load of meta data | |
| */ | |
| namespace CranleighSchool\CranleighPeople\ImportViaJson; | |
| use CranleighSchool\CranleighPeople\Plugin; | |
| use Exception; | |
| use WP_REST_Request; | |
| class Import | |
| { | |
| /** | |
| * @throws Exception | |
| */ | |
| public function __invoke(WP_REST_Request $data): array | |
| { | |
| if (Plugin::getPluginSetting('isams_controlled') !== 'yes') { | |
| throw new \Exception('The plugin is not set to be under ISAMS Control', 400); | |
| } | |
| $dataReceived = $data->get_body(); | |
| $objData = json_decode($dataReceived, true); | |
| $people = array_map(function ($person) { | |
| return new PersonMap($person); | |
| }, $objData['data']); | |
| $result = []; | |
| foreach ($people as $person) { | |
| $result[] = (new ImportPerson($person))->handle(); | |
| } | |
| return [ | |
| 'from' => RestSetup::get_client_ip(), | |
| 'success' => $result, | |
| ]; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * This is the `ImportPerson` class, which does most of the heavy lifting for WordPress and either updates, or creates | |
| * a new "Person" custom post type in the WordPress database. | |
| * | |
| * The "Person" custom post type is editable within WordPress, but we have a warning on the UI, as any edits will just | |
| * get overwritten at the next sync. | |
| */ | |
| namespace CranleighSchool\CranleighPeople\ImportViaJson; | |
| use CranleighSchool\CranleighPeople\Exceptions\StaffNotFoundException; | |
| use CranleighSchool\CranleighPeople\Exceptions\TooManyStaffFound; | |
| use CranleighSchool\CranleighPeople\Metaboxes; | |
| use CranleighSchool\CranleighPeople\Plugin; | |
| use Exception; | |
| use WP_Post; | |
| class ImportPerson | |
| { | |
| use SaveMetaTrait; | |
| private WP_Post $post; | |
| public function __construct(protected readonly PersonMap $person) | |
| { | |
| } | |
| /** | |
| * @throws Exception | |
| */ | |
| public function handle(): string | |
| { | |
| try { | |
| $post_id = (new FindStaffPost($this->person->school_initials))->find()->ID; | |
| } catch (TooManyStaffFound $exception) { | |
| (new SlackMessage('Too many staff found for ' . $this->person->school_initials . ', aborting.'))->send(); | |
| return $this->person->school_initials . ' [FAILED]'; | |
| } catch (StaffNotFoundException $exception) { | |
| $post_id = 0; | |
| (new SlackMessage('Going to create ' . $this->person->school_initials))->send(); | |
| } | |
| $this->updateOrCreate($post_id); | |
| return $this->person->school_initials; | |
| } | |
| /** | |
| * @param int $post_id | |
| * | |
| * @return void | |
| * @throws Exception | |
| */ | |
| private function updateOrCreate(int $post_id = 0): void | |
| { | |
| error_log('Start ' . self::present_tense_verb($post_id) . ' ' . $this->person->school_initials); | |
| $post_title = $this->person->prename . ' ' . $this->person->surname; | |
| $post_content = is_null($this->person->biography) ? '' : $this->person->biography; | |
| $staff_post = wp_insert_post( | |
| array( | |
| 'post_type' => Plugin::POST_TYPE_KEY, | |
| 'post_status' => $this->get_wp_post_status(), | |
| 'ID' => $post_id, | |
| 'post_title' => $post_title, | |
| 'post_content' => $post_content, | |
| 'post_name' => sanitize_title($post_title), | |
| ) | |
| ); | |
| if (is_wp_error($staff_post)) { | |
| throw new Exception('Could not save post', 500); | |
| } | |
| $this->post = get_post($staff_post); | |
| /** | |
| * We only set the Username if we are creating a new Staff. | |
| */ | |
| if ($post_id === 0) { | |
| $this->saveMeta('username', $this->person->school_initials); | |
| } | |
| $this->saveMeta('surname', $this->person->surname); | |
| $this->saveMeta('leadjobtitle', self::getLeadJobTitle($this->person->job_titles)); | |
| $this->saveMeta('position', self::getOtherJobTitles($this->person->job_titles)); | |
| $this->saveMeta('full_title', $this->person->label_salutation); | |
| $this->saveMeta('qualifications', self::qualificationsAsList($this->person->qualifications)); | |
| $this->saveMeta('email_address', $this->person->email); | |
| $this->saveMeta('phone', $this->person->phone); | |
| $this->saveMeta('prefix', $this->person->title['name']); | |
| $this->saveMeta('prename', $this->person->prename); | |
| // Set the Taxonomy Objects | |
| (new SetStaffCategoryTaxonomy($this->post, $this->person))->handle(); | |
| (new SetStaffHousesTaxonomy($this->post, $this->person))->handle(); | |
| (new SetStaffSubjectsTaxonomy($this->post, $this->person))->handle(); | |
| /** TODO: I think we'll do the Image in a separate request | |
| // Do the Profile Pic | |
| $image = self::featureImageLogic($staff_post, $person); | |
| if ($image instanceof WP_Post) { | |
| // Updated / Created Featured Image; | |
| } elseif ($image === true) { | |
| // Removed Image, because no image was on People Manager | |
| } elseif ($image === NULL) { | |
| // No logic was hit, changing nothing. | |
| } else { | |
| throw new Exception('Error whilst checking featureImageLogic. Type: ' . gettype($image), 500); | |
| } | |
| */ | |
| } | |
| /** | |
| * Just a nice little helper function. | |
| * | |
| * @param int $post_id | |
| * | |
| * @return string | |
| */ | |
| private static function present_tense_verb(int $post_id): string | |
| { | |
| if ($post_id === 0) { | |
| return 'creating'; | |
| } else { | |
| return 'updating'; | |
| } | |
| } | |
| /** | |
| * @return string | |
| */ | |
| private function get_wp_post_status(): string | |
| { | |
| if ($this->person->system_status !== '1') { | |
| return 'private'; | |
| } | |
| if ($this->person->hide_from_website !== NULL && strtotime($this->person->hide_from_website) > time()) { | |
| return 'pending'; | |
| } | |
| return 'publish'; | |
| } | |
| /** | |
| * @param array $jobTitles | |
| * | |
| * @return string|null | |
| */ | |
| public static function getLeadJobTitle(?array $jobTitles): ?string | |
| { | |
| if (isset($jobTitles[0])) { | |
| return $jobTitles[0]; | |
| } | |
| return NULL; | |
| } | |
| /** | |
| * @param array $jobTitles | |
| * | |
| * @return array | |
| */ | |
| public static function getOtherJobTitles(?array $jobTitles): array | |
| { | |
| if (!is_array($jobTitles)) { | |
| return []; | |
| } | |
| array_shift($jobTitles); | |
| return $jobTitles; | |
| } | |
| /** | |
| * @param array $qualifications | |
| * | |
| * @return string | |
| */ | |
| public static function qualificationsAsList(array $qualifications): string | |
| { | |
| return implode(', ', $qualifications); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * This is the API Resource that sets the schema for the Person object that we push across to WordPress as a JSON object | |
| */ | |
| declare(strict_types=1); | |
| namespace App\Http\Resources; | |
| use App\Enums\PersonStatus; | |
| use App\Models\IsamsRecord; | |
| use App\Models\School; | |
| use App\Models\Title; | |
| use Carbon\Carbon; | |
| use Illuminate\Http\Request; | |
| use Illuminate\Http\Resources\Json\JsonResource; | |
| use Illuminate\Support\Collection; | |
| class PersonResource extends JsonResource | |
| { | |
| /** | |
| * Transform the resource into an array. | |
| * | |
| * @param Request $request | |
| * @return array | |
| * | |
| * @psalm-return array{id: int, isams_id: int, school: School, school_initials: string, title: Title, prename_surname: string, forename: string, prename: string, surname: string, salutation: ?string, label_salutation: string, email: string, phone: string, qualifications: Collection, houses: array, roles: array, job_titles: array, departments: array, subjects: array, biography: string, teacher: bool, tutor: bool, system_status: PersonStatus, photo_uri: null|string, photo_updated: \DateTime, created_at: \DateTime, updated_at: \DateTime, hide_from_website: null|\DateTime} | |
| */ | |
| public function toArray(Request $request): array | |
| { | |
| if ($this->photo !== null) { | |
| $photo_uri = trailingslashit(url('storage/mugshots')).$this->photo->filename; | |
| } else { | |
| $photo_uri = null; | |
| } | |
| if (is_null($this->isamsRecord)) { | |
| $this->isamsRecord = new IsamsRecord; | |
| } | |
| return [ | |
| 'id' => $this->id, | |
| 'isams_id' => $this->isams_id, | |
| 'school' => School::query()->find($this->school_id), | |
| 'school_initials' => $this->school_initials, | |
| 'title' => Title::query()->find($this->isamsRecord->title_id), | |
| 'prename_surname' => $this->isamsRecord->prename.' '.$this->isamsRecord->surname, | |
| 'forename' => $this->isamsRecord->forename, | |
| 'prename' => $this->isamsRecord->prename, | |
| 'surname' => $this->isamsRecord->surname, | |
| 'salutation' => $this->isamsRecord->salutation, | |
| 'label_salutation' => $this->isamsRecord->label_salutation, | |
| 'email' => $this->isamsRecord->email, | |
| 'phone' => $this->isamsRecord->phone, | |
| 'qualifications' => $this->isamsRecord->qualifications->pluck('abbr'), | |
| 'houses' => $this->isamsRecord->houses, | |
| 'roles' => $this->isamsRecord->roles, | |
| 'job_titles' => $this->isamsRecord->job_titles, | |
| 'departments' => $this->isamsRecord->departments, | |
| 'subjects' => $this->isamsRecord->subjects, | |
| 'biography' => $this->biography, | |
| 'teacher' => $this->isamsRecord->teacher, | |
| 'tutor' => $this->isamsRecord->personal_tutor, | |
| 'system_status' => $this->system_status, | |
| 'photo_uri' => $photo_uri, | |
| 'photo_updated' => $this->photo?->updated_at?->toDateTimeString(), | |
| 'created_at' => $this->created_at?->toDateTimeString(), | |
| 'updated_at' => $this->updated_at?->toDateTimeString(), | |
| 'hide_from_website' => $this->hide_from_website?->toDateTimeString(), | |
| ]; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * This is the Artisan Command that runs each night. | |
| * | |
| * You'll see from the `handleChunk` method, we are posting the JSON to the WordPress endpoint. (In chunks because of a large dataset) | |
| * | |
| * After updated the main content, we then run back through and run `updatePhotos` method. | |
| */ | |
| declare(strict_types=1); | |
| namespace App\Console\Commands; | |
| use App\Enums\PersonStatus; | |
| use App\Exceptions\WebsiteForbiddenException; | |
| use App\Http\Resources\PersonCollection; | |
| use App\Models\Person; | |
| use App\Models\School; | |
| use Artisan; | |
| use Exception; | |
| use Illuminate\Console\Command; | |
| use Illuminate\Support\Collection; | |
| use Illuminate\Support\Facades\Http; | |
| use Illuminate\Support\Facades\Log; | |
| use Illuminate\Support\Facades\Validator; | |
| use Illuminate\Validation\ValidationException; | |
| class PushToWebsite extends Command | |
| { | |
| /** | |
| * The name and signature of the console command. | |
| * | |
| * @var string | |
| */ | |
| protected $signature = 'twk:update {school} {user?} {--dd}'; | |
| /** | |
| * The console command description. | |
| * | |
| * @var string | |
| */ | |
| protected $description = 'Push the latest data to the website.'; | |
| private int $numChunks = 10; | |
| private School $school; | |
| /** | |
| * Execute the console command. | |
| */ | |
| public function handle(): int | |
| { | |
| try { | |
| Validator::make($this->arguments(), [ | |
| 'school' => ['required', 'int', 'exists:schools,id'], | |
| 'user' => 'nullable|string|exists:people,school_initials,school_id,'.$this->argument('school'), | |
| ])->validate(); | |
| } catch (ValidationException $e) { | |
| $this->error('Invalid arguments: '.$e->getMessage()); | |
| return self::FAILURE; | |
| } | |
| $this->school = School::query()->find($this->argument('school')); | |
| $chunks = $this->buildCollectionInChunks(); | |
| $this->info('Connecting to '.$this->school->name.' website...'); | |
| $this->info('Starting to iterate through '.count($chunks).' chunks of data...'); | |
| try { | |
| foreach ($chunks as $chunk) { | |
| $this->handleChunk($chunk); | |
| } | |
| } catch (WebsiteForbiddenException $exception) { | |
| return self::FAILURE; | |
| } | |
| $this->line('Finished: Iterated through '.$chunks->flatten()->count().' people in total'); | |
| Log::info('Finished: Pushing '.$chunks->flatten()->count().' to '.$this->school->name.' website'); | |
| $this->removeOldRecords(); | |
| return self::SUCCESS; | |
| } | |
| private function removeOldRecords(): void | |
| { | |
| $usernames = Person::query() | |
| ->where('school_id', $this->argument('school')) | |
| ->where('system_status', PersonStatus::FORMER) | |
| ->whereBetween('updated_at', [now()->subDay(), now()]) | |
| ->pluck('school_initials'); | |
| foreach ($usernames as $username) { | |
| Artisan::call('twk:remove', [ | |
| 'school' => $this->argument('school'), | |
| 'user' => $username, | |
| ]); | |
| $this->info("Removed old record for user: {$username}"); | |
| } | |
| $this->alert('Removed '.$usernames->count().' records from the website.'); | |
| } | |
| private function buildCollectionInChunks(): Collection | |
| { | |
| $username = $this->argument('user'); | |
| $queryBuilder = Person::query()->with('isamsRecord')->current()->where('school_id', $this->school->id); | |
| if ($username) { | |
| $queryBuilder = $queryBuilder->where('school_initials', $username); | |
| $this->info("Specific user: {$username}"); | |
| } | |
| return $queryBuilder | |
| ->get() | |
| ->sortBy('isamsRecord.surname') | |
| ->chunk($this->numChunks); | |
| } | |
| /** | |
| * @throws WebsiteForbiddenException | |
| */ | |
| private function handleChunk(Collection $data): void | |
| { | |
| $json = PersonCollection::make($data)->toJson(); | |
| try { | |
| $websiteResponse = Http::toWebsite($this->school) | |
| ->withBody($json) | |
| ->post('/wp-json/people/import') | |
| ->throw(); | |
| $this->alert('Processing: '.$data->pluck('school_initials')->implode(', ')); | |
| if ($this->option('dd')) { | |
| dd($websiteResponse->object()); | |
| } | |
| } catch (Exception $e) { | |
| $this->error('Website error: '.$e->getMessage()); | |
| if ($e->getCode() === 403) { | |
| throw new WebsiteForbiddenException('Website error: '.$e->getMessage()); | |
| } | |
| return; | |
| } | |
| $this->uploadPhotos($data); | |
| } | |
| private function uploadPhotos(Collection $data): void | |
| { | |
| $data->each(function (Person $person): void { | |
| if ($person->photo && $person->photo->mime_type) { | |
| try { | |
| Http::toWebsite($this->school) | |
| ->withBody($person->photo->raw_contents, $person->photo->mime_type) | |
| ->post('/wp-json/people/photo/'.$person->school_initials.'?last_updated='.$person->photo->updated_at, | |
| [ | |
| 'last_updated' => $person->photo->updated_at, | |
| ])->throw(); | |
| $this->line('Sending photo for: '.$person->school_initials); | |
| } catch (Exception $e) { | |
| $this->error("{$person->school_initials} failed to upload photo: ".$e->getMessage()); | |
| } | |
| } else { | |
| $this->warn("{$person->school_initials} has no photo."); | |
| } | |
| }); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment