I slightly modified your code and tested on my WordPress installation.
If you using a filter, like ‘the_content’, which recieves a content as an argument,
you must also return this content back.
Here is working version of your function with comments.
//this filter recieves $content, which you should always return,
//otherwise you will not see the content
function posts_related($content){
if (is_single()){
$custom_query = new WP_Query( array(
'posts_per_page' => 8,
'post__not_in' => array(get_queried_object_id()),
'orderby' => 'rand',
));
//lets define $related variable outside of while loop
// and if statement to make it visible
$related = '';
if ( $custom_query->have_posts() ) :
while ( $custom_query->have_posts() ) : $custom_query->the_post();
//use functions with "get_" to recieve a value
// and not echo it
$permalink = get_the_permalink();
$title = get_the_title();
if ( has_post_thumbnail() ) {
//here we receiving just url with
//get_the_post_thumbnail_url()
$related .= '<a href="' . $permalink . '">
<img src="' . get_the_post_thumbnail_url() . '" />
</a>';
}else{
$related .= '<a href="' . $permalink . '"><b>' . $title . '</b></a>';
}
endwhile;
wp_reset_postdata();
//return content + related posts
//if is_singular and we have related post
return $content . $related;
else :
//if is_singular but no posts were found
$related .= '<p>Nothing to show.</p>';
endif;
//if is_singular and we have related posts
//return content + related posts
//or content + "nothing to show"
return $content . $related;
}
//if page is not a single,
//just return content
return $content;
}
P.S. Usually related posts are posts with the same category or tag, just orderby with rand
will return random posts, but I guess you know this.