# booktics/1.0.23/base/abstracts/post-model.php

Booktics – Appointment Booking Calendar for Service Businesses, version 1.0.23. 588 lines.

- Page: https://pluginprobe.com/plugins/booktics/1.0.23/code/base/abstracts/post-model.php
- Raw: https://pluginprobe.com/plugins/booktics/1.0.23/raw/base/abstracts/post-model.php
- Modified: 2026-07-29T07:25:12+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/booktics/1.0.23/code/base/abstracts/post-model.php#L10-L20`.

```php
<?php

namespace Booktics\Abstracts;

use Booktics\Query_Builders\Query_Builder;
use Exception;
use JsonSerializable;
use WP_Error;
use WP_Post;
use WP_Query;

/**
 * PostModel class
 */
abstract class Post_Model implements JsonSerializable {
    /**
     * List of accepted post statuses
     *
     * @var array
     */
    public static $accepted_statuses = array(
        'active',
        'deactive',
        'draft',
        'publish',
        'cancelled',
        'pending',
    );

    /**
     * Store post
     *
     * @var Object
     */
    protected $post;

    /**
     * Store meta property
     *
     * @var array
     */
    protected $fillable = array();

    /**
     * Store post attributes
     *
     * @var array
     */
    protected $attributes = array();

    /**
     * Store origianl data
     *
     * @var array
     */
    protected $original = array();

    /**
     * Store post type
     *
     * @var string
     */
    protected $post_type;

    /**
     * Constructor for post model class
     *
     * @param Object $post Post model
     *
     * @return  void
     */
    public function __construct( $post = null ) {
        if ( $post instanceof WP_Post ) {
            $this->post = $post;
        } elseif ( is_numeric( $post ) ) {
            $this->post = get_post( $post );
        }

        if ( $this->post && $this->post->post_type !== $this->get_post_type() ) {
            $this->post = null;
        }

        if ( $this->post ) {
            $this->load_attributes();
        }
    }

    /**
     * Get post type
     *
     * @return  string  Get post type name
     */
    abstract protected function get_post_type();

    /**
     * Load attributes
     *
     * @return  void  Set all necessary attributes
     */
    protected function load_attributes() {
        $this->attributes = array(
            'id'      => $this->post->ID,
            'title'   => $this->post->post_title,
            'content' => $this->post->post_content,
            'status'  => $this->post->post_status,
            'link'    => $this->get_link( $this->post ),
        );

        $custom_meta = get_post_meta( $this->post->ID );

        foreach ( $this->get_fillable() as $key => $value ) {
            if ( isset( $this->attributes[ $key ] ) && ! empty( $this->attributes[ $key ] ) ) {
                continue;
            }
            $this->attributes[ $key ] = get_post_meta( $this->post->ID, $key, true );
        }
        $this->original = $this->attributes;
    }

    /**
     * Get the permalink for the post
     *
     * @param $post
     *
     * @return string
     */
    public function get_link( $post ) {
        return $post->post_type && get_post_type_object( $this->post->post_type ) ?
            get_post_permalink( $this->post->ID ) : '';
    }

    /**
     * Get attributes
     *
     * @param string $key Property name
     *
     * @return  mixed   Meta data from any model
     * @throws Exception
     */
    public function __get( $key ) {
        if ( isset( $this->get_fillable()[ $key ] ) ) {
            return $this->attributes[ $key ] ?? $this->get_fillable()[ $key ];
        }

        // translators: %s is the name of the undefined property.
        throw new Exception( sprintf( esc_html__( 'Undefined property  %s', 'booktics' ), esc_html( $key ) ) );
    }

    /**
     * Set attributes
     *
     * @param string $key Model meta property
     * @param mixed $value Post meta data
     *
     * @return  void Set meta data
     * @throws Exception
     */
    public function __set( $key, $value ) {

        if ( ! isset( $this->get_fillable()[ $key ] ) && ! in_array( $key, array( 'link', 'customer' ) ) ) {

            // translators: %s is the name of the undefined property.
            throw new Exception( sprintf( esc_html__( 'Undefined property %s', 'booktics' ), esc_html( $key ) ) );
        }

        $this->attributes[ $key ] = $value;
    }

    /**
     * Fillable getter
     *
     * @return array
     */
    protected function get_fillable() {
        $this->fillable = apply_filters( 'booktics_post_model_fillable', $this->fillable, $this->get_post_type() );

        return $this->fillable;
    }

    /**
     * Find any model by using id
     *
     * @param integer $id Post id
     *
     * @return  null | PostModel
     */
    public static function find( $id ) {
        $post = get_post( $id );
        if ( ! $post ) {
            return null;
        }

        $instance = new static( $post );

        return $instance;
    }

    /**
     * Get all posts
     *
     * @param array $args Post arguments
     *
     * @return  array   collection of PostModel
     */
    public static function all( $args = array() ) {
        $args = array_merge(
            array(
				'post_type'   => ( new static() )->get_post_type(),
				'post_status' => static::$accepted_statuses,
				'numberposts' => - 1,
            ), $args
        );

        $posts = get_posts( $args );

        return array_map( fn( $p ) => new static( $p ), $posts );
    }

    /**
     * Pagenate all collections
     *
     * @param integer $per_page Number of items perpage
     * @param integer $page Current page number
     * @param array $args other args
     *
     * @return  array   collection of records
     */
    public static function paginate( $per_page = 10, $page = 1, $args = array() ) {
        $args = array_merge(
            array(
				'post_type'      => ( new static() )->get_post_type(),
				'post_status'    => static::$accepted_statuses,
				'posts_per_page' => $per_page,
				'paged'          => $page,
            ), $args
        );

        $query = new WP_Query( $args );

        return array(
            'items'        => array_map(
                function ( $post ) {
                    return new static( $post );
                }, $query->posts
            ),
            'total'        => $query->found_posts,
            'per_page'     => $per_page,
            'current_page' => $page,
            'last_page'    => ceil( $query->found_posts / $per_page ),
        );
    }


    /**
     * Filter post by using condition
     *
     * @param string $meta_key Meta key for the post model
     * @param mixed $meta_value Post meta value
     *
     * @return  array collection of post model
     */
    public static function where( $meta_key, $meta_value ) {
        $args = array(
            'post_type'   => ( new static() )->get_post_type(),
            'post_status' => static::$accepted_statuses,
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering posts by meta fields
            'meta_query'  => array(
                array(
                    'key'   => $meta_key,
                    'value' => $meta_value,
                ),
            ),
        );

        $posts = get_posts( $args );

        return array_map(
            function ( $p ) {
                return new static( $p );
            }, $posts
        );
    }

    /**
     * Create a single row
     *
     * @param array $attributes collection of attributes
     *
     * @return  null | Post_Model | WP_Error
     */
    public static function create( array $attributes ) {
        $validation = self::validate_attributes( $attributes );
        if ( is_wp_error( $validation ) ) {
            return $validation;
        }

        $post_data = array(
            'post_type'    => ( new static() )->get_post_type(),
            'post_status'  => $attributes['status'] ?? 'deactive',
            'post_title'   => $attributes['title'] ?? '',
            'post_content' => $attributes['content'] ?? $attributes['description'] ?? '',
        );

        $post_id = wp_insert_post( $post_data );

        if ( is_wp_error( $post_id ) ) {
            return null;
        }

        $instance = new static( $post_id );

        if ( ! is_array( $attributes ) ) {
            return $instance;
        }

        foreach ( $attributes as $key => $value ) {
            if ( ! in_array( $key, array( 'title', 'content', 'status' ) ) ) {
                update_post_meta( $post_id, $key, $value );

                $instance->$key = $value;
            }
        }
        $instance->link = $instance->get_link( $instance->post );

        return $instance;
    }

    /**
     * Update a certain record
     *
     * @param array $attributes Collection of attributes
     *
     * @return  WP_Error|Post_Model
     */
    public function update( array $attributes ) {
        if ( ! $this->post ) {
            return new WP_Error( 'post_not_found', esc_html__( 'Cannot update a non-existent post.', 'booktics' ) );
        }

        $validation = self::validate_attributes( $attributes );
        if ( is_wp_error( $validation ) ) {
            return $validation;
        }

        $post_data = array( 'ID' => $this->post->ID );

        if ( isset( $attributes['title'] ) ) {
            $this->title             = $attributes['title'];
            $post_data['post_title'] = $attributes['title'];
        }

        if ( isset( $attributes['content'] ) ) {
            $this->content             = $attributes['content'];
            $post_data['post_content'] = $attributes['content'];
        }

        if ( isset( $attributes['status'] ) ) {
            $this->status             = $attributes['status'];
            $post_data['post_status'] = $attributes['status'];
        }

        // Handle permalink update when 'link' attribute is provided
        if ( isset( $attributes['link'] ) ) {
            $new_slug = $this->extract_slug_from_link( $attributes['link'] );
            if ( $new_slug ) {
                $post_data['post_name'] = sanitize_title( $new_slug );
            }
        }

        wp_update_post( $post_data );

        // Update custom meta fields
        foreach ( $attributes as $key => $value ) {
            if ( ! in_array( $key, array( 'title', 'content', 'status', 'link' ) ) ) {
                update_post_meta( $this->post->ID, $key, $value );
                $this->attributes[ $key ] = $value;
            }
        }

        // Refresh the post object and regenerate the link if permalink was updated
        if ( isset( $attributes['link'] ) ) {
            $this->post = get_post( $this->post->ID );
            $this->attributes['link'] = $this->get_link( $this->post );
        }

        return $this;
    }

    /**
     * Extract slug from a full permalink URL
     *
     * @param string $link Full permalink URL
     * @return string|null Extracted slug or null if invalid
     */
    private function extract_slug_from_link( $link ) {
        // Remove trailing slash
        $link = rtrim( $link, '/' );
        
        // Extract the last part of the URL as the slug
        $slug = basename( $link );
        
        // Basic validation - ensure it's not empty and contains valid characters
        if ( empty( $slug ) || $slug === '.' || $slug === '..' ) {
            return null;
        }
        
        return $slug;
    }


    /**
     * Save a single record
     *
     * @return  Post_Model instance of the PostModel
     * @throws Exception
     */
    public function save() {

        if ( ! $this->post ) {
            return static::create( $this->attributes );
        }

        $post_data = array(
            'ID'           => $this->post->ID,
            'post_title'   => $this->title,
            'post_content' => $this->content,
        );

        wp_update_post( $post_data );

        foreach ( $this->attributes as $key => $value ) {
            if ( ! in_array(
                $key, array(
					'ID',
					'title',
					'content',
                )
            ) && $value !== ( $this->original[ $key ] ?? null ) ) {
                update_post_meta( $this->post->ID, $key, $value );
            }
        }

        return $this;
    }

    /**
     * Delete a record
     *
     * @return  WP_Post|false|null Post data on success, false or null on failure.
     */
    public function delete() {
        if ( $this->post ) {
            return wp_delete_post( $this->post->ID, true );
        }

        return false;
    }

    /**
     * Query records from posts
     *
     * @return  Object
     */
    public static function query() {
        return new Query_Builder( new static() );
    }

    /**
     * Validate attributes
     *
     * @param array $attributes Post data
     *
     * @return  WP_Error|true
     */
    protected static function validate_attributes( $attributes = array() ) {
        if ( ! is_array( $attributes ) ) {
            return new WP_Error( 'invalid_attributes', esc_html__( 'Attributes must be an array.', 'booktics' ) );
        }

        foreach ( $attributes as $key => $value ) {
            if ( ! isset( ( new static() )->get_fillable()[ $key ] ) && ! in_array(
                $key, array(
					'ID',
					'team_member_details',
					'category_details',
					'shortcode',
					'service_details',
					'duration_unit',
					'customer',
					'service',
					'team_member',
                )
            ) ) {
                // translators: %s: Property name
                return new WP_Error( 'invalid_property', sprintf( esc_html__( 'Invalid property: %s', 'booktics' ), $key ) );
            }
        }

        return true;
    }

    /**
     * Convert the model instance to an array.
     *
     * @return array
     */
    public function to_array(): array {
        $data = array();

        foreach ( $this->get_fillable() as $key => $default ) {
            if ( $key === 'user_email' ) {
                $data['email'] = $this->$key;
                continue;
            }

            $value = $this->$key;

            // Unserialize if necessary (object instantiation disallowed — see helper).
            if ( is_string( $value ) && $this->is_serialized( $value ) ) {
                $value = \booktics_safe_maybe_unserialize( $value );
            }

            // Normalize value to default type
            if ( gettype( $value ) !== gettype( $default ) ) {
                if ( gettype( $default ) === 'array' && is_string( $value ) && $this->is_serialized( $value ) ) {
                    $value = \booktics_safe_maybe_unserialize( $value );
                } elseif ( gettype( $default ) === 'array' ) {
                    $value = $default; // Set to default empty array if the value doesn't match
                }
            }

            // Cast specific keys to objects or arrays if needed
            if ( in_array(
                $key, array(
					'team_member',
					'holiday',
					'categories',
					'additional_durations',
                )
            ) ) {
                $value = is_array( $value ) ? $value : array();
            }

            if ( in_array( $key, array( 'schedule', 'custom_field' ) ) ) {
                $value = empty( $value ) ? (object) array() : $value;
            }

            // Cast specific keys to boolean
            if ( in_array(
                $key, array(
					'enable_custom_schedule',
					'enable_custom_holiday',
                )
            ) ) {
                $value = filter_var( $value, FILTER_VALIDATE_BOOLEAN );
            }

            $data[ $key ] = $value ?? $default;
        }

        return $data;
    }


    /**
     * Specify data which should be serialized to JSON.
     *
     * @return array
     */
    public function jsonSerialize(): array {
        return $this->to_array();
    }

    /**
     * Check if a string is serialized
     *
     * @param string $data The data to check.
     *
     * @return bool True if the data is serialized, false otherwise.
     */
    protected function is_serialized( $data ) {
        // Use WordPress core's purely structural check. Unlike the previous
        // implementation, \is_serialized() never calls unserialize(), so testing
        // attacker-controlled meta can no longer trigger PHP Object Injection.
        return is_string( $data ) && \is_serialized( $data );
    }
}

```
