###When `save_post` runs, it has already saved the post on the database.

If you are using ACF, it has built-in validation.

However, if you need to validate things outside of ACF, such as post_title, things get a little more complicated.

Looking into WordPress core code, more specifically at the `wp-includes/post.php`'s `update_post()` function, there is no built-in way to intercept a request before it is saved on the database.

However, we can hook `pre_post_update` and use `header()` and `get_post_edit_link()` to prevent the post from being saved.


    <?php
    
    /**
    *   Performs custom validation on custom post type "Site"
    */
    function custom_post_site_save($post_id, $post_data) {
        # If this is just a revision, don't do anything.
        if (wp_is_post_revision($post_id))
            return;
    
        if ($post_data['post_type'] == 'site') {
            # In this example, we will deny post titles with less than 5 characters
            if (strlen($post_data['post_title'] < 5)) {
                # Add a notification
                update_option('my_notifications', json_encode(array('error', 'Post title can\'t be less than 5 characters.')));
                # And redirect
                header('Location: '.get_edit_post_link($post_id, 'redirect'));
                exit;
            }
        }
    }
    add_action( 'pre_post_update', 'custom_post_site_save', 10, 2);
    
    /**
    *   Shows custom notifications on wordpress admin panel
    */
    function my_notification() {
        $notifications = get_option('my_notifications');
    
        if (!empty($notifications)) {
            $notifications = json_decode($notifications);
            #notifications[0] = (string) Type of notification: error, updated or update-nag
            #notifications[1] = (string) Message
            #notifications[2] = (boolean) is_dismissible?
            switch ($notifications[0]) {
                case 'error': # red
                case 'updated': # green
                case 'update-nag': # ?
                    $class = $notifications[0];
                    break;
                default:
                    # Defaults to error just in case
                    $class = 'error';
                    break;
            }
    
            $is_dismissable = '';
            if (isset($notifications[2]) && $notifications[2] == true)
                $is_dismissable = 'is_dismissable';
    
            echo '<div class="'.$class.' notice '.$is_dismissable.'">';
               echo '<p>'.$notifications[1].'</p>';
            echo '</div>';
    
            # Let's reset the notification
            update_option('my_notifications', false);
        }
    }
    add_action( 'admin_notices', 'my_notification' );